Skip to content

Commit 685d9f3

Browse files
fix(runtime, plugin-sharing): a share link's resolve answer describes the fields it returns, so the shared-record page can show authored labels (#27107)
<!-- claude-projects-attribution: {"github_login":"os-project-manager"} --> _Requested by **Leehom** · [project thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1X5AGM4y97vjVvqa9btSXiHi)_ Fixes #26991 Clause-②: no Metadata-protocol: none ## 维护者速读 - **改了什么**:分享链接的解析接口(`GET /api/v1/share-links/TOKEN/resolve`)在原有的 `record` / `link` / `redactFields` 之外,多返回一个 `fields`:只描述这条记录实际下发的字段,每个字段给出作者写的标签、类型、下拉选项的标签与颜色、货币设置、查找字段指向的对象和显示字段。两个出口(plugin-sharing 路由与 runtime 调度器)都经同一个 `buildShareLinkResolveAnswer`(`@objectstack/types`)生成答案。 - **为什么改**:打开分享链接的访客通常未登录,读不到对象元数据(`/meta/object` 对匿名返回 401),所以分享页只能把字段键名转成标题显示,作者写的中文标签、选项名、货币和查找名都显示不出来。 - **风险与回滚**:低。只新增一个响应键,原有三个键不变;被脱敏的字段在 `record` 和 `fields` 里都不出现,记录没下发的字段(如 `internal`)也不出现,不会多暴露对象的其它字段;`/meta` 的匿名拒绝不动。回滚即 revert。 - **先看**:`packages/types/src/share-link-resolve-answer.ts`(`projectSharedRecordFields` 的边界:只看记录里的键);`packages/runtime/src/domains/share-links.ts` 与 `packages/plugins/plugin-sharing/src/share-link-routes.ts` 的 resolve 出口。 - **两分钟验证**:`pnpm --filter @objectstack/runtime exec vitest run src/domains/share-links-field-projection.test.ts`,预期 12/12 通过(两个出口各 5 条,外加两条两出口答案相等)。 - **需要你做的**:无,席位落地。 ## What changes `GET /api/v1/share-links/TOKEN/resolve` answered `{ record, link, redactFields }` on both mounts. It now answers `{ record, link, redactFields, fields }`, built once by `buildShareLinkResolveAnswer` in `@objectstack/types` for both the plugin-sharing routes (`registerShareLinkRoutes`) and the runtime dispatcher's `/share-links` domain. `fields` is keyed by field name. Each entry holds only what a read-only renderer needs: | Key | When present | |---|---| | `label`, `type` | as the object schema holds them (authored labels; the resolve read path has no caller-locale seam, and none is built) | | `options` | a field declaring options: each option's `value`, `label`, `color` (never `default` or visibility rules) | | `multiple` | `true` only | | `currencyConfig` | `currencyMode`, `defaultCurrency` | | `reference`, `displayField` | a lookup's target object and authored display field | - **The boundary.** A field is projected only when the served record carries its key after redaction AND the shared object declares it. So a redacted field is absent from both `record` and `fields`, a field the record read never returns (`internal: true`) is absent from both, and no other field of the object is described. The meta door's anonymous refusal (`GET /meta/object/NAME` answers 401) is unchanged; nothing new reads object metadata for an anonymous caller beyond what the served record's own keys already name. - **Lookups.** Measured: the record read (`engine.find(object, { where: { id }, limit: 1 })` under a system context) expands nothing, so a lookup's value is its stored id. The projection names `reference` and the authored `displayField`; no second read expands names. - **System columns.** The served record carries the registry-injected columns (`created_at`, `updated_at`, `organization_id`, `created_by`, `updated_by`, `owner_id`, `owning_business_unit_id`, `queue`), and the projection describes them like any other served key (e.g. `owner_id` → `{ label: 'Owner', type: 'lookup', reference: 'sys_user' }`). Measured from a real resolve in the new test's harness. ### Placement of the one helper `@objectstack/types`, as `share-link-resolve-answer.ts` beside `share-password-header.ts`. `@objectstack/plugin-sharing` is a dev dependency of `@objectstack/runtime`, so the plugin cannot be the home; both packages already depend on `@objectstack/types` (and on `@objectstack/spec`, which may hold no runtime logic), so the helper adds no dependency edge. This is the same placement and reasoning the shared password-header reading already uses. The two `applyRedaction` copies are folded into it as `redactSharedRecord`: the plugin's copy (it also stripped each object of a top-level array). Redaction is unchanged for every input either mount passes: both pass the one row object they read, where the two copies were identical. ### Unchanged The existing three keys and their values; refusal arms and their order; response headers; the `/messages` route; the console's fold (`ui/apps/console/src/pages/shared-record-shape.ts` reads `record`, `link`, `redactFields` off `WireResolvedShare` and ignores any other key, so the additive `fields` does not change what it parses). No `packages/spec` schema: the resolve answer is not spec-declared today, and this PR does not declare it. No `ui/**` change: the console reading `fields` is the follow-up the card names. ## Tests - `runtime` `src/domains/share-links-field-projection.test.ts` (new, 12): both mounts' production entries over one real `ObjectQL` + `driver-sql` (better-sqlite3) + `ShareLinkService`, on an object with an authored label, a select with option labels and a colour, a fixed-currency field, a lookup with `displayField`, a per-link redacted field and an `internal` field. Per mount: `fields` keys equal the record's keys that the object declares; the redacted field is in neither; control: with no redaction it is in both; the `internal` field is in neither; the label/type/options/currency/lookup entries are exact and the lookup's value is the stored id. Parity: for each link, the two mounts' `data` are equal. - `types` `src/share-link-resolve-answer.test.ts` (new, 7): the boundary, the display-only keys, the nine link columns (never the hash, allowlist or creator), an unreadable schema projects nothing, a redacted key is skipped even if still present, `redactSharedRecord` shapes. - `plugin-sharing` `share-link-envelope.conformance.test.ts`: the resolve key-set pin now names `fields`. - `runtime` `domain-handler-registry.test.ts`: the extracted share-links domain's engine double answers `getSchema` (the route reads it off the same engine the refusal probe already reads it from) and the case asserts the redacted key is absent from `fields`. <details><summary>Red first, ablation</summary> - Red, at `d17dd70e` (the new test on `origin/main` `cbb3a1a1` code): `Tests 10 failed | 2 passed (12)`; every per-mount case failed on the absent `fields` (`the answer carries a field projection: expected undefined to be type of 'object'`); the two parity cases passed, both mounts lacking the key alike. - Green at `653c867a`: `Tests 12 passed (12)`. - Ablation (committed fix, `scripts/ablation-replace.mjs`): `for (const key of Object.keys(record)) {` → `for (const key of Object.keys(declared)) {` (project the object's whole field map). Anchor 1 → 0, blob `7c0a9303886d` → `19df7f482a58`; `pnpm --filter @objectstack/types build`; `ablation-dist-preflight.mjs @objectstack/types 'Object.keys(declared)'` hit `dist/index.js` and `dist/index.mjs`. Run: `Tests 4 failed | 8 passed (12)`: "projects exactly the served record keys…" and "a field the record read never returns (internal)…" on both mounts. Restore: `git checkout HEAD --`, `git diff HEAD` empty, hash equals the HEAD blob, rebuild, preflight `--absent` exit 0 with the whole tree clean, run `Tests 12 passed (12)`. </details> ## Acceptance notes - `fields` labels are authored, untranslated: the resolve path has no caller-locale seam today. A localized projection would be a new channel for the anonymous caller, which the dispatch excluded. - A `dynamic` currency field displays in the tenant default currency (`localization.currency`), which an anonymous page cannot read; the projection carries `currencyConfig` as declared and nothing more. - The console still humanizes keys until its follow-up reads `fields` (`SharedRecordFields.tsx`, `shared-record-shape.ts`); its comments describing the answer as `{ record, link, redactFields }` move with that follow-up. ## Verification All local runs at `2804d207` unless noted. <details><summary>Suites and typecheck</summary> - `pnpm --filter @objectstack/types test && … typecheck`: `Test Files 30 passed (30)`, `Tests 796 passed (796)`, exit 0. - `pnpm --filter @objectstack/plugin-sharing test && … typecheck`: `Test Files 57 passed (57)`, `Tests 1278 passed (1278)`, exit 0 (at `c1f772d0`; later commits touch only runtime tests). - `pnpm --filter @objectstack/runtime test` (full `local` project, at `c1f772d0`): `Tests 1 failed | 7609 passed | 19 skipped (7629)`; the one failure was `domain-handler-registry.test.ts`'s share-links case, whose engine double had no `getSchema` (500). Fixed in `2804d207`; re-run of `src/domain-handler-registry.test.ts` plus `src/domains/`: `Test Files 156 passed (156)`, `Tests 4353 passed (4353)`. `pnpm --filter @objectstack/runtime typecheck` (with its test typecheck): exit 0. - `pnpm exec eslint --no-inline-config packages/types packages/plugins/plugin-sharing packages/runtime`: exit 0. - `test:repo` not run locally: the diff adds no repo-project test and no derived family names it; CI `Test Core` runs it. - Dogfood: n/a (no `!` in the title, no `(narrowing)` arm). </details> <details><summary>Derived gates (local), reconciled with --ran</summary> `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 64 families on this diff. Each was run with its exit code captured before any pipe; the test-reading families (`check:engine-double-contract`, `check:objectql-double-limit`, `check:query-options-erasure`, `check:driver-memory-census`, `check:where-matcher`, `check:cross-package-test-inputs`, `check:test-source-alias`, `check:nul-bytes`, `check:system-context-census`) and `check-plugin-teardown-shape --self-test` were re-run on `2804d207`, all exit 0. - 62 exit 0. - NOT MEASURED, 2, both PREREQUISITE NOT MET (exit 3): `pnpm check:dual-build-cjs-loads` and `pnpm check:i18n` read every package's built `dist/`, which needs a full workspace build this worktree did not run (the diff touches no translation bundle). CI's `Lint & Repo Gates` runs both on a full build. - `--ran` reconciliation: `64 derived famil(ies) accounted for — 62 run, 2 NOT-MEASURED (2 DERIVED from a recorded exit 3)`, exit 0. - `check-plugin-teardown-shape --self-test` first exited 3 on the shallow clone; after `git fetch --depth=1 origin 621a487` it exits 0 (48 cases). </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01FEkQ5os139Pfixtv7wjwGB)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 6817bf1 commit 685d9f3

9 files changed

Lines changed: 603 additions & 58 deletions
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
'@objectstack/types': patch
3+
'@objectstack/runtime': patch
4+
'@objectstack/plugin-sharing': patch
5+
---
6+
7+
fix(runtime, plugin-sharing): a share link's resolve answer describes the fields it returns — authored label, type, picklist option labels, currency and lookup display bits — so the shared-record page no longer has only the field keys to show (#26991)
8+
9+
Clause-②: no
10+
Metadata-protocol: none
11+
12+
`GET /api/v1/share-links/TOKEN/resolve` answered `{ record, link, redactFields }` on both of its mounts (the `plugin-sharing` routes and the runtime dispatcher's `/share-links` domain). Its visitor is usually anonymous, and `GET /api/v1/meta/object/NAME` refuses an anonymous caller, so a page rendering the shared record could only humanize each key. The answer now also carries `fields`, keyed by field name:
13+
14+
- **What an entry holds.** The field's authored `label` and `type`; a picklist's `options` (`value`, `label`, `color`); `multiple` when it is `true`; a currency field's `currencyConfig` (`currencyMode`, `defaultCurrency`); and a lookup's `reference` (the target object) and authored `displayField`. Labels are the authored ones the object schema holds, untranslated. A lookup's value in `record` is unchanged: the stored id.
15+
- **Which fields.** Exactly the keys `record` carries that the shared object declares. A field the link redacts is absent from both `record` and `fields`; a field the record read never returns (`internal`) is absent from both; no other field of the object is described. The meta door's anonymous refusal is unchanged.
16+
- **One builder.** Both mounts build the whole answer through `buildShareLinkResolveAnswer` (new in `@objectstack/types`, beside `redactSharedRecord` and `projectSharedRecordFields`), so they answer the same shape. Redaction is unchanged.
17+
18+
The existing keys are unchanged, so a reader of the old answer reads the new one as before.

‎packages/plugins/plugin-sharing/src/share-link-envelope.conformance.test.ts‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
* So the target shape was not a design decision. `runtime/src/domains/share-links.ts`
2222
* serves these same five paths (for cloud's per-environment kernels it is the
2323
* designed PRIMARY surface), and has always answered `data: link`, `data: links`,
24-
* `data: { ok: true }`, `data: { record, link, redactFields }`, `data: rows`. This
24+
* `data: { ok: true }`, `data: { record, link, redactFields, fields }`, `data: rows`. This
2525
* module converged onto that. The `sameShapeAsDispatcher` suite at the bottom is
2626
* what keeps the two from drifting apart again — the failure mode #3833 hit one
2727
* domain over, where each surface held its own copy of a mapping.
@@ -500,8 +500,9 @@ describe('share-link envelope (#3983) — same shape as the dispatcher twin', ()
500500
expect(revoke.body.data).toEqual({ ok: true }); // dispatcher: data: { ok: true }
501501

502502
const resolve = await drive(mount().http, `GET ${B}/:token/resolve`, { params: { token: 'tok_abcdefgh' } });
503-
expect(Object.keys(resolve.body.data).sort()) // dispatcher: data: { record, link, redactFields }
504-
.toEqual(['link', 'record', 'redactFields']);
503+
// dispatcher: data: { record, link, redactFields, fields } — one builder, #26991
504+
expect(Object.keys(resolve.body.data).sort())
505+
.toEqual(['fields', 'link', 'record', 'redactFields']);
505506
});
506507

507508
it('redaction still happens, and `redactFields` names what was stripped', async () => {

‎packages/plugins/plugin-sharing/src/share-link-routes.ts‎

Lines changed: 11 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
* GET /api/v1/share-links → list links → data: link[]
88
* (?object, ?recordId, ?includeRevoked)
99
* DELETE /api/v1/share-links/:idOrToken → revoke → data: { ok: true }
10-
* GET /api/v1/share-links/:token/resolve → resolve token → data: { record, link, redactFields }
10+
* GET /api/v1/share-links/:token/resolve → resolve token → data: { record, link, redactFields, fields }
1111
* GET /api/v1/share-links/:token/messages → conversation rows → data: ai_messages[]
1212
* * /api/v1/share-links/* → every other verb and path: refused
1313
* (anonymous 401, signed-in 404)
@@ -26,7 +26,9 @@
2626
* which gates audience / expiry / password and stamps usage).
2727
* 2. Fetch the underlying record with a SYSTEM context (so the read
2828
* bypasses normal RLS — the token IS the authorisation).
29-
* 3. Strip `redactFields` from the record before returning.
29+
* 3. Strip `redactFields` from the record before returning, and describe
30+
* the fields it still carries (`fields`: label, type, option labels,
31+
* currency and lookup display bits — `buildShareLinkResolveAnswer`).
3032
*
3133
* For browser-rendered share pages, the front-end calls this endpoint
3234
* and renders the response read-only.
@@ -47,6 +49,9 @@ import { sendOk, sendError, sendServerFault } from '@objectstack/types';
4749
// [#22049] The one reading of the password header pair, shared with the
4850
// dispatcher twin — see `presentedPassword` below.
4951
import { readSharePasswordHeader, SHARE_PASSWORD_VARY, type SharePasswordHeaderReading } from '@objectstack/types';
52+
// [#26991] The resolve answer — redaction and the field display projection
53+
// included — from the ONE builder the dispatcher twin serves it with.
54+
import { buildShareLinkResolveAnswer } from '@objectstack/types';
5055
import type { ShareLinkExecutionContext } from '@objectstack/spec/contracts';
5156
import type { ExecutionContext } from '@objectstack/spec/kernel';
5257
import type { ErrorCode } from '@objectstack/spec/api';
@@ -186,7 +191,7 @@ interface ShareLinkFamilyRoute {
186191
* It has always answered in the declared envelope with `data` as the payload —
187192
* `data: link`, `data: links`, `data: { ok: true }`, `data: { record, link,
188193
* redactFields }`, `data: rows`. This module answered `{ link }`, `{ links }`,
189-
* `{ ok: true }`, `{ record, link, redactFields }`, `{ data: rows }`. Same
194+
* `{ ok: true }`, `{ record, link, redactFields, fields }`, `{ data: rows }`. Same
190195
* routes, two shapes, decided by which surface happened to mount them — the
191196
* asymmetry #3636 fixed for `/i18n`, one domain over.
192197
*
@@ -271,18 +276,6 @@ function setPublicResponseHeaders(res: IHttpResponse): void {
271276
for (const [name, value] of Object.entries(SHARE_LINK_PUBLIC_RESPONSE_HEADERS)) res.header(name, value);
272277
}
273278

274-
/** Strip `redactFields` from a record (also removes from nested arrays of objects). */
275-
function applyRedaction(record: any, redactFields: string[]): any {
276-
if (!record || typeof record !== 'object' || redactFields.length === 0) return record;
277-
if (Array.isArray(record)) return record.map((r) => applyRedaction(r, redactFields));
278-
const out: any = {};
279-
for (const [k, v] of Object.entries(record)) {
280-
if (redactFields.includes(k)) continue;
281-
out[k] = v;
282-
}
283-
return out;
284-
}
285-
286279
export function registerShareLinkRoutes(
287280
http: IHttpServer,
288281
service: ShareLinkService,
@@ -548,21 +541,9 @@ export function registerShareLinkRoutes(
548541
return sendError(res, 410, 'RECORD_GONE', 'The shared record no longer exists');
549542
}
550543

551-
sendOk(res, {
552-
record: applyRedaction(record, resolved.redactFields),
553-
link: {
554-
id: resolved.link.id,
555-
token: resolved.link.token,
556-
object_name: resolved.link.object_name,
557-
record_id: resolved.link.record_id,
558-
permission: resolved.link.permission,
559-
audience: resolved.link.audience,
560-
expires_at: resolved.link.expires_at,
561-
label: resolved.link.label,
562-
created_at: resolved.link.created_at,
563-
},
564-
redactFields: resolved.redactFields,
565-
});
544+
// [#26991] The redacted record, the link, and the display projection of
545+
// exactly the fields that record carries (`fields`).
546+
sendOk(res, buildShareLinkResolveAnswer(record, resolved, engine.getSchema?.(resolved.link.object_name)));
566547
},
567548
failure: 'Failed to resolve link',
568549
});

‎packages/runtime/src/domain-handler-registry.test.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -490,6 +490,12 @@ describe('HttpDispatcher extracted domains (PR-4: share-links)', () => {
490490
shareLinks: { resolveToken },
491491
objectql: {
492492
find,
493+
// [#26991] The resolve answer projects the served fields from the
494+
// shared object's schema, which the route reads off the same engine.
495+
getSchema: vi.fn().mockReturnValue({
496+
name: 'account',
497+
fields: { name: { name: 'name', label: 'Account Name', type: 'text' }, secret: { name: 'secret', type: 'text' } },
498+
}),
493499
getObjects: vi.fn().mockReturnValue({}),
494500
registry: { getObject: vi.fn().mockReturnValue(null), getRegisteredTypes: vi.fn().mockReturnValue([]) },
495501
},
@@ -500,6 +506,8 @@ describe('HttpDispatcher extracted domains (PR-4: share-links)', () => {
500506
expect(record?.name).toBe('Acme');
501507
// redactFields stripped before the record leaves the server.
502508
expect(record?.secret).toBeUndefined();
509+
// ...and from the field projection, which describes only what the record carries.
510+
expect(result.response?.body?.data?.fields).toEqual({ name: { label: 'Account Name', type: 'text' } });
503511
});
504512

505513
// ── #4038: one shape, no duplicate keys ──────────────────────────────

0 commit comments

Comments
 (0)