Repository navigation
feat(service-print): the print door — POST /api/v1/print/records prints a record as the caller; the showcase invoice declares Generate PDF - #24194
Conversation
… (wip) Claude-Session: https://claude.ai/code/session_01Nk17Z26X5DB59iQztDBADH Co-authored-by: Claude <noreply@anthropic.com>
…wcase invoice's Generate PDF action (wip) Claude-Session: https://claude.ai/code/session_01Nk17Z26X5DB59iQztDBADH Co-authored-by: Claude <noreply@anthropic.com>
…fault log Claude-Session: https://claude.ai/code/session_01Nk17Z26X5DB59iQztDBADH Co-authored-by: Claude <noreply@anthropic.com>
…e print plugin from source Claude-Session: https://claude.ai/code/session_01Nk17Z26X5DB59iQztDBADH Co-authored-by: Claude <noreply@anthropic.com>
…til a request arrives Claude-Session: https://claude.ai/code/session_01Nk17Z26X5DB59iQztDBADH Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 39 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 6 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 4 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 418ad3608969d7670826dadd8e2155d172af2358 && git checkout 418ad3608969d7670826dadd8e2155d172af2358
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 7d6ec91d2f89e834fc5b82183a95cb3c5427a882 8e8cfc60af7cf52b7b535b4e6ccafd1e37e84597 && git checkout -B drift-repro 7d6ec91d2f89e834fc5b82183a95cb3c5427a882 && git merge --no-ff 8e8cfc60af7cf52b7b535b4e6ccafd1e37e84597
node scripts/docs-audit/affected-docs.mjs --json 7d6ec91d2f89e834fc5b82183a95cb3c5427a882
|
…ansport Claude-Session: https://claude.ai/code/session_01Nk17Z26X5DB59iQztDBADH Co-authored-by: Claude <noreply@anthropic.com>
… real auth service Claude-Session: https://claude.ai/code/session_01Nk17Z26X5DB59iQztDBADH Co-authored-by: Claude <noreply@anthropic.com>
席位核对ACCEPT ·
Generated by Claude Code |
Fixes #22270
Clause-②: no
Print page ④ of #8346 (ruling of record
6051470224): the door a record-page "generate PDF" action calls, and one declared action on the showcase invoice. The PDF is attached by card ③'s service (#22269, landed asb15f8a1997), once, as the caller; this PR adds no second attach.Dispatched by the
domain:servicesseat 1 (seat post #6021), claim6234883974, sessionsession_01Nk17Z26X5DB59iQztDBADH. The grade is the maintainer's 「PDF 要赶 18.0 正式版」.What it adds
POST /api/v1/print/records(packages/services/service-print/src/print-routes.ts), mounted byPrintServicePluginon the kernel's HTTP server atkernel:ready, on every placement path. The JSON body is the in-process request,{ app, object, recordId, page, filename? }. The door resolves the caller with the platform's one resolver (resolveAuthzContext), assembles the execution context as the REST face does (assembleExecutionContext), and hands that context and the request toIPrintService.printRecordToFile. It answers201with{ fileId, attachmentId, size, filename }. It holds no attachments handle, so it cannot attach.PrintServicePlugin, andshowcase_invoicedeclaresshowcase_generate_invoice_pdf: atype: 'api'action,target: '/api/v1/print/records',recordIdParam: 'recordId',bodyExtra: { app, object, page }naming the invoice's default record pageshowcase_invoice_document(the page that declaresprint). The zh-CN label and success message are translated at birth (the example's i18n ratchet).print-route-ledger.ts(oneserver-onlyrow) with its package-local conformance test, read by the dogfood live-mount parity gate beside the other ledgers.The door's order, and what it relays
Door refusals, each before the service is asked, so nothing is minted and no browser is started:
SERVICE_UNAVAILABLE/ 503; an API key its lifecycle refuses relays its own 403 and code.UNAUTHENTICATED/ 401 (the shared anonymous floor).evaluateAuthGate, as the other doors).PERMISSION_DENIED/ 403, with a message saying the print was refused because the session is an impersonation. No new error code.VALIDATION_ERROR/ 400. A misspelled field is refused, never ignored.Then every refusal card ③ declares keeps its
codeandstatusunchanged:PERMISSION_DENIED/ 403,SERVICE_UNAVAILABLE/ 503 (no render driver configured),RESOURCE_NOT_FOUND/ 404,VALIDATION_ERROR/ 400 (a page withoutprint),RECORD_NOT_FOUND/ 404 (a caller who cannot read the record),FILES_DISABLED/ 403,ATTACHMENT_PARENT_ACCESS/ 403,TIMEOUT/ 504,INTERNAL_ERROR/ 500,PAYLOAD_TOO_LARGE/ 413. A 4xx keeps the service's message. A 5xx answers with the door's own sentence for its status and the service's text goes to the server fault log (declaredRefusalMessage/sendServerFault, the shared rule every door applies to a declared 5xx, because a 5xx text can carry what the engine said about the host). An undeclared throw isINTERNAL_ERROR/ 500.Door decisions (the seat's ruling, applied)
packages/specchange: the session the auth service reads for the request carriesimpersonatedBy(better-auth's admin plugin field, which@objectstack/plugin-authstores assys_session.impersonated_by). Measured statically (the admin plugin's session schema returns the field;plugin-auth'scustomSessionpassessessionthrough unchanged) and on a real showcase boot with a local-only scratch test that was deleted afterwards: an impersonation session's in-process session read carried the impersonating administrator's id, and a user's own session carriednull. The door reads the mark off the same session read that admitted the caller, and only for a session credential (a key-admitted request is judged by the key alone). Described abstractly here on purpose.sys_file.owner_id,sys_attachment.uploaded_by) attribute the print to the caller.IAuthServiceprovider that marks impersonation some other way is not recognised. Its reach is what card ③ already ships.Why this path
/api/v1/print/records, under the versioned API prefix as/api/v1/storageis, plural per the REST naming rule, and mirroringprintRecordToFile. A body-addressed route rather than/:object/:recordIdbecause both consoleapihandlers at the pin (useConsoleActionRuntime.apiHandler,RecordDetailView.apiHandler) seedrecordIdParaminto the body and mergebodyExtra, while{field}path interpolation exists on one of them only. The route is plugin-mounted (the storage and settings pattern), so one owner serves it, and only where print is composed.Measured: the console at the pin (
.objectui-sha5e15928b)type: 'api'action with an absolute target is POSTed as JSON (credentials: 'include', bearer added for a same-origin target): the collected params, thenrecordIdParamseeded from the row or the page record, thenbodyExtra. A non-2xx answer becomes the action's error witherror.messagefrom the envelope, so each refusal above reaches the user as a message.page:headeronly, and a print page may not hold one (PRINTABLE_PAGE_COMPONENT_TYPESrefusespage:header). The invoice's default record page is a print page sinceb15f8a1997, so itsrecord_headeractions — this PR's Generate PDF, and the existing Submit for Sign-off — are not drawn there, and neither are the system Edit / Share / Delete actions. The action is therefore also placed atlist_item(the invoice list's row menu), where the console draws it today. The record-page host is a console change: reported to the seat for objectui, not made here.Landing sites outside the claim's file surface, and why
packages/qa/dogfood/test/route-ledger-live-mount-parity.dogfood.test.ts(2 lines): the showcase now mounts the door, and that gate's "every live mount is ledgered" direction reds on an unledgered mount. The print ledger joins its union.scripts/check-route-envelope.mjs(oneMODULESrow): a*-routes.tsmodule must be declared there; declared at0 / 0 / 0(every body through the shared pair).content/docs/ui/print-pages.mdx: the door and its action, documented.pnpm-lock.yaml(6 lines): two workspace links,@objectstack/typesforservice-printand@objectstack/service-printfor the showcase.packages/services/service-print/package.jsonand itsvitest.config.tsalias are inside the package, outsidesrc/**.Clause-② measured
dist/index.d.tsof the built@objectstack/service-printexports the same 14 names as before (src/index.tsis byte-identical to the base). The class declaration gains oneprivate mountDoor;member. No new plugin option, no schema change, no new error code.Tests (head
021f86051)pnpm --filter @objectstack/service-print test: 8 files, 109 tests passed.typecheckexit 0 (both new test files are in the tsc program, counted with--listFiles).print-routes.test.ts(25 tests) drives the mounted handler with the real resolver and the realPrintServiceover recording doubles: the success path stores exactly onesys_attachmentrow for the print (and none through the system writer), the context the door built reaches the mint by identity, and every refusal is asserted bycode+statuson an envelope that parses asApiError.examples/app-showcase: 36 files, 431 tests passed, typecheck exit 0,objectstack validatepassed (at69f5ac29e; the last commit changed one comment inprint-routes.ts). The newinvoice-generate-pdf-action.test.ts(4 tests, re-run at021f86051) sends the body the console builds from the declaration to the door the showcase's own plugin mounts: with no engine configured it is answeredSERVICE_UNAVAILABLE/ 503.69f5ac29e):route-ledger-live-mount-parity,print-service-archive,anonymous-door-sweep: 3 files, 37 tests passed (the door is reachably mounted and ledgered, and answers an anonymous probe 401).Ablation, each through
scripts/ablation-replace.mjs(anchor hit 1 → 0, blob changed), each red, each restored with the blob equal toHEADandgit diff HEADempty: the impersonation guard (an impersonated print answered success), the anonymous floor (PERMISSION_DENIED/ 403 instead of 401), the body's closed set, the query refusal, and card ③'s no-engine (INTERNAL_ERROR/ 500 instead of 503), no-printand read-as-caller guards (each print then answered success).Gates
Derived with
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackat021f86051: 128 commands, all run locally on that head, all exit 0 (each exit captured before any pipe).--ranreconciliation: 128 derived, 128 run, 0 NOT-MEASURED, 0 UNRUN, every family carrying its exit code. The earlier run on86e1da243was red in two real places, both fixed here:check:i18n-coverage(the showcase's untranslated count grew 414 → 416, the new action's label and message; now translated) andcheck:test-source-alias(the showcase's new import of@objectstack/service-print; now aliased to source). Two more answered PREREQUISITE NOT MET (exit 3) until the workspace was built, then 0. CI runs the full set on this PR.Patch round 1 (head
8e8cfc60a)print-routes.tscallsresolveAuthzContext, so it is now aguardedrow ofTRANSPORT_LEDGERinpackages/core/src/security/authz-store-unavailable.test.ts(its caller-resolutioncatchrelays the outage with its own status and code).@objectstack/core#test:repowas red without the row (10 transports against 9 rows) and is green with it. That file is the engine lane's; one row, declared by the seat.packages/qa/dogfood/test/print-door-impersonation.dogfood.test.ts, on a real showcase boot with no print engine: an administrator's own session is not refused for impersonation (the service answersSERVICE_UNAVAILABLE/ 503), and an impersonation session that administrator starts is refused at the door (PERMISSION_DENIED/ 403, the message naming impersonation). The session mark comes from the composed auth service, so a change that drops or renames it reds this pin instead of turning the refusal off. Ablated once through the built package: with the guard disabled the pin answered 503 instead of 403; restored withgit diff HEADempty and the marker gone fromdist/.main; on this head the touched suites (service-print 109, showcase 431, core repo 85, dogfood 4 files / 38) and the re-derived 130 gates are green locally, and CI is green.Acceptance notes
OS_PRINT_*set, a showcase boot now logs onewarnthat printing is off, naming the settings (the plugin's existing "absence is loud" line).route-ledger-live-mount-parity.dogfood.test.ts's header still says "Five route ledgers"; the union it reads was already wider (settings) before this PR.021f86051the branch was behindmainby three commits that touch none of its files. Patch round 1 mergedmain(merge base7d6ec91d2).printshows none of its header actions — no Edit / Share / Delete, no declared record_header action (the showcase invoice hides "Submit for Sign-off" and "Generate PDF") objectui#12906 (p1). On a record whose default record page declaresprint, the console draws none of the header actions, so the record page shows no "Generate PDF". The showcase action is also placed atlist_itemmeanwhile.session.impersonatedBy, is not a field the session contract declares. That is filed as spec(contracts):AuthSessionApi.getSessionclaims to declare every field its readers read, but its result declares three; readers cast to readsession.id,token,user.email, the auth gate andimpersonatedBy#24196 for the spec lane. Until it lands, the dogfood pin above is the guard against the mark going away.Generated by Claude Code