@geniee-inc/sfa-kit 1.18.0 → 1.19.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/GUIDE.md +35 -15
  2. package/package.json +1 -1
  3. package/src/bin/sfa-kit-bundle.js +16 -8
  4. package/src/bundle.d.ts +1 -2
  5. package/src/lib/auth/DevToolbar.js +3 -6
  6. package/src/lib/auth/SfaLoginGate.js +17 -8
  7. package/src/lib/auth/StandalonePreviewContext.d.ts +3 -3
  8. package/src/lib/auth/StandalonePreviewContext.js +1 -1
  9. package/src/lib/auth/useEmbedContext.d.ts +3 -1
  10. package/src/lib/auth/useEmbedContext.js +3 -1
  11. package/src/lib/bundle/build-static-bundle.d.ts +37 -11
  12. package/src/lib/bundle/build-static-bundle.js +75 -35
  13. package/src/lib/bundle/request-signature.d.ts +13 -1
  14. package/src/lib/bundle/request-signature.js +21 -6
  15. package/src/lib/components/FieldRenderer.d.ts +2 -1
  16. package/src/lib/components/FieldRenderer.js +32 -19
  17. package/src/lib/components/RecordList.js +32 -4
  18. package/src/lib/components/RecordView.js +27 -7
  19. package/src/lib/components/fieldDisplayText.d.ts +11 -0
  20. package/src/lib/components/fieldDisplayText.js +51 -0
  21. package/src/lib/hooks/useFieldTypes.d.ts +5 -0
  22. package/src/lib/hooks/useFieldTypes.js +5 -0
  23. package/src/lib/hooks/useObjectCatalog.d.ts +6 -0
  24. package/src/lib/hooks/useObjectCatalog.js +6 -0
  25. package/src/lib/hooks/useRecord.d.ts +20 -0
  26. package/src/lib/hooks/useRecord.js +53 -0
  27. package/src/lib/hooks/useRecordList.d.ts +7 -1
  28. package/src/lib/hooks/useRecordList.js +3 -3
  29. package/src/lib/hooks/useRelatedRecords.d.ts +5 -1
  30. package/src/lib/provider/SfaClient.js +13 -8
  31. package/src/lib/provider/types.d.ts +7 -1
  32. package/src/lib/signing/compute-signature.d.ts +4 -2
  33. package/src/lib/signing/compute-signature.js +4 -2
  34. package/src/lib/signing/create-manifest.d.ts +8 -6
  35. package/src/lib/signing/create-manifest.js +8 -6
  36. package/src/lib/signing/index.d.ts +1 -1
  37. package/src/lib/signing/types.d.ts +33 -4
  38. package/src/lib/version.d.ts +1 -1
  39. package/src/lib/version.js +1 -1
  40. package/src/signing.d.ts +2 -2
package/GUIDE.md CHANGED
@@ -25,16 +25,27 @@ Three additional subpaths are available: `@geniee-inc/sfa-kit/signing` exposes b
25
25
 
26
26
  ### Static bundle manifest (custom-tab uploads)
27
27
 
28
- A page uploaded through GENIEE's SFA/CRM settings UI as a custom tab is a framework-free static bundle — `index.html` + `manifest.json` + `assets/` — not a Next.js app. Its `manifest.json` carries the three fields Keystone's `BundleSignatureVerifier` reads: `studio_version`, `content_hash`, `signature`.
28
+ A page uploaded through GENIEE's SFA/CRM settings UI as a custom tab is a framework-free static bundle — `index.html` + `manifest.json` + `assets/` — not a Next.js app. Its `manifest.json` carries three fields:
29
+
30
+ | Field | Signed | Meaning |
31
+ | ----------------- | ------ | -------------------------------------------------------------------------------------------- |
32
+ | `content_hash` | Yes | SHA-256 over every other file in the bundle (see `computeContentHash`) |
33
+ | `signature` | - | HMAC-SHA-256 of `content_hash`, computed by Keystone |
34
+ | `sfa_kit_version` | No | The sfa-kit version that ran the build. Keystone stores it with the page for troubleshooting |
35
+
36
+ Editing any file after signing changes `content_hash`, and a new `content_hash` needs a new signature that only Keystone can compute, so an edited bundle fails verification.
37
+
38
+ A manifest that also carries `studio_version` is the legacy format: its signature covers `"{studio_version}:{content_hash}"`. Keystone accepts both and tells them apart by whether `studio_version` is present.
29
39
 
30
40
  **The signing key lives only in Keystone, so a page build never computes the signature itself.** It hashes the bundle locally and asks Keystone to sign:
31
41
 
32
42
  ```ts
43
+ import pkg from '@geniee-inc/sfa-kit/package.json';
33
44
  import {
34
45
  readBundleFiles,
35
46
  computeContentHash,
36
47
  } from '@geniee-inc/sfa-kit/signing';
37
- import type { BundleManifest } from '@geniee-inc/sfa-kit/signing';
48
+ import type { ContentHashManifest } from '@geniee-inc/sfa-kit/signing';
38
49
 
39
50
  const files = await readBundleFiles('dist');
40
51
  const contentHash = computeContentHash(files);
@@ -47,16 +58,13 @@ const response = await fetch(`${apiBase}/v1/pages/sign`, {
47
58
  'Content-Type': 'application/json',
48
59
  Authorization: `Bearer ${accessToken}`,
49
60
  },
50
- body: JSON.stringify({
51
- studio_version: studioVersion,
52
- content_hash: contentHash,
53
- }),
61
+ body: JSON.stringify({ content_hash: contentHash }),
54
62
  });
55
63
  if (!response.ok) throw new Error(`Signing failed: ${response.status}`);
56
64
  const { signature } = (await response.json()) as { signature: string };
57
65
 
58
- const manifest: BundleManifest = {
59
- studio_version: studioVersion, // the Studio build version, NOT the sfa-kit npm version
66
+ const manifest: ContentHashManifest = {
67
+ sfa_kit_version: pkg.version,
60
68
  content_hash: contentHash,
61
69
  signature,
62
70
  };
@@ -72,7 +80,6 @@ import { buildStaticBundle } from '@geniee-inc/sfa-kit/bundle';
72
80
  await buildStaticBundle({
73
81
  entry: 'entry.tsx', // renders <SfaLoginGate>…</SfaLoginGate> into #root
74
82
  outDir: 'dist',
75
- studioVersion: '0.5.0', // the Studio build version, NOT the sfa-kit npm version
76
83
  apiBase: 'https://keystone-api-staging.chikyu.mobi',
77
84
  accessToken: token,
78
85
  });
@@ -84,7 +91,6 @@ Or from the command line, which is the shorter path when a build script drives i
84
91
  SFA_KIT_ACCESS_TOKEN=<token> npx sfa-kit-bundle \
85
92
  --entry entry.tsx \
86
93
  --out dist \
87
- --studio-version 0.5.0 \
88
94
  --api-base https://keystone-api-staging.chikyu.mobi
89
95
  ```
90
96
 
@@ -95,7 +101,9 @@ Then zip the **contents** of `dist/` — the bundle's files have to sit at the z
95
101
  `esbuild` is an optional peer dependency; install it alongside the kit. Its options are fixed: JS, JSX, TS and CSS bundle, but there is no loader for images or fonts, so a page importing a `.png`, `.svg` or a font — including through `url()` in CSS — fails to build. Inline such assets as data URIs, or reference them by absolute URL.
96
102
  Nothing is written when signing fails, so a failed build never leaves an unsigned bundle behind to be uploaded by mistake.
97
103
 
98
- `studioVersion` has no default on purpose. The signature covers `"{studio_version}:{content_hash}"` and Keystone reads the field back separately, so a guessed value produces a valid signature over the wrong version.
104
+ `sfa_kit_version` is filled in automatically with the version of the sfa-kit that ran the build. esbuild bundles whichever copy of sfa-kit the entry module imports, which is normally the same one, but a project with two installed copies can record one version and ship the other.
105
+
106
+ `studioVersion` (`--studio-version`) is deprecated. When given, the bundle is signed in the legacy format and the manifest carries `studio_version`. Omit it.
99
107
 
100
108
  ### Signing exports
101
109
 
@@ -109,7 +117,7 @@ The lower-level pieces `buildStaticBundle` uses, for a caller that builds its ow
109
117
  | `createManifest` | **Yes** |
110
118
  | `createManifestFromDirectory` | **Yes** |
111
119
 
112
- The three key-taking exports exist for the service that holds the key. Calling them from a page build means inventing a key, which Keystone will not accept — and the mistake is invisible at first, because bundle verification ships in `disabled` mode: the upload succeeds, and only starts failing once the mode is switched to `enforced`.
120
+ The three key-taking exports produce the legacy format, signed over `"{studio_version}:{content_hash}"`. They exist for the service that holds the key. Calling them from a page build means inventing a key, which Keystone will not accept — and the mistake is invisible at first, because bundle verification ships in `disabled` mode: the upload succeeds, and only starts failing once the mode is switched to `enforced`.
113
121
 
114
122
  `content_hash` must come from `computeContentHash`, never from hashing files back to back. The algorithm length-prefixes each path and each file's contents before feeding them into one running SHA-256; without those prefixes `"ab" + "c"` and `"a" + "bc"` hash identically, and the result never matches Keystone's own computation.
115
123
 
@@ -222,6 +230,8 @@ const { record, isLoading, error, refetch } = useRecord('companies', 'abc123');
222
230
 
223
231
  Returns `{ record: SfaRecord | null, isLoading, error, refetch }`.
224
232
 
233
+ The result always describes the `collection` and `recordId` passed right now: from the first render after either changes it reports no record and no error — with `isLoading` while the new fetch runs — so the previous record or error never shows as the answer to the new one. An empty `recordId` with a collection given sets `error` instead of fetching. Disabling the hook (`enabled: false`) without changing them keeps the record already fetched.
234
+
225
235
  ### useRecordList
226
236
 
227
237
  Fetches a paginated list of records. Supports cursor-based pagination (Keystone API).
@@ -277,7 +287,7 @@ const result = useRecordList('companies', {
277
287
 
278
288
  Disabling the hook, or clearing `collection`, discards any request still in flight: a response that lands after the caller stopped asking never becomes the result, and the `'loading'` a discarded refetch set does not outlive it — the hook goes back to how the query last ended.
279
289
 
280
- Returns `{ items, totalCount, hasMore, pageIndex, status, isLoading, error, refetch, goToPage }`.
290
+ Returns `{ items, totalCount, hasMore, pageIndex, status, isLoading, error, refetch, goToPage }`. `totalCount` is `null` when it is not known: the backend does not report it — Keystone pages with cursors and returns no total — or no fetch has succeeded since the query last changed (an earlier answer to the same query does not count). Read it together with `status`, and use `hasMore` to tell whether another page exists.
281
291
 
282
292
  For cross-object queries (fetching child records of a parent), prefer `useRelatedRecords` which builds the filter automatically from describe metadata.
283
293
 
@@ -351,7 +361,7 @@ const { items, isLoading, error } = useRelatedRecords(
351
361
 
352
362
  Check `status` for **every** hook on the page, not just the first one. Each related-records hook fails independently: with several fetches firing on mount, one can hit a rate limit or a timeout while the others succeed, and the failed one renders as an empty section unless its own status is read.
353
363
 
354
- Returns `{ items, totalCount, hasMore, pageIndex, status, goToPage, isLoading, error, refetch, relationship }`.
364
+ Returns `{ items, totalCount, hasMore, pageIndex, status, goToPage, isLoading, error, refetch, relationship }`. As in `useRecordList`, `totalCount` is `null` when it is not known.
355
365
 
356
366
  ### Bulk writes
357
367
 
@@ -715,7 +725,7 @@ The state for `status === 'error'`. Renders `⚠ 取得失敗` and, when given `
715
725
 
716
726
  Props: `{ onRetry?: () => void, message?: string, retryLabel?: string }`. Without `onRetry` no link is rendered — a dead control is worse than none.
717
727
 
718
- The server's error text is deliberately not shown: it is wording the reader cannot act on. Pages that need it still have `error` from the hook, and `RecordList` / `RecordView` report it through their `onError` prop.
728
+ The server's error text is deliberately not shown: it is wording the reader cannot act on. Pages that need it still have `error` from the hook, and `RecordList` / `RecordView` report it through their `onError` prop (`RecordView` does not for an empty record id — see [RecordView](#recordview)).
719
729
 
720
730
  `refetch` retries whichever step failed. On `useRelatedRecords` that includes a failed describe: retrying only the list would do nothing, because without relationship metadata the list never ran.
721
731
 
@@ -814,6 +824,10 @@ Props:
814
824
 
815
825
  Each group: `title?` (heading), `layout?` (`"1-col"` | `"2-col"` | `"3-col"`), `fields` (field names).
816
826
 
827
+ **States.** While the record is fetched it shows "Loading...". A failed fetch shows `FetchErrorState` and calls `onError`. When no record is specified — `collection` or `recordId` is empty — it shows `EmptyState` with "レコードが指定されていません" and does **not** call `onError`: nothing was fetched, so there is no failure to report. `useRecord` on its own still sets `error` for an empty record id (a collection given, no record picked), so a page built on the hook can word that case itself.
828
+
829
+ **Heading.** `title` if given, else the record's own title, else the values of its title fields joined by spaces. Only values a reader understands go into the heading — a reference or choice field without a label is left out rather than showing its id or code.
830
+
817
831
  ### RecordList
818
832
 
819
833
  Displays a paginated table of records.
@@ -834,6 +848,8 @@ Props:
834
848
 
835
849
  `RecordView` takes the same prop and, without it, names its `title` — the label it already shows as the heading — so a page that passed `title="会社"` gets `⚠ 会社の取得に失敗` for free.
836
850
 
851
+ **States.** While the list is fetched it shows "Loading...". A failed fetch shows `FetchErrorState` and calls `onError`. Without a `collection` nothing is asked, so instead of "0 records" it shows `EmptyState` with "オブジェクトが指定されていません".
852
+
837
853
  ### FieldRenderer
838
854
 
839
855
  Renders a single field value based on its kind. Used internally by RecordView and RecordList. Use directly when building custom layouts.
@@ -842,6 +858,10 @@ Renders a single field value based on its kind. Used internally by RecordView an
842
858
  <FieldRenderer fieldValue={someField} fieldName="company_name" />
843
859
  ```
844
860
 
861
+ It shows `label` (formatted by the API). When `label` is empty or `null`, it falls back to `value` only for `text` / `number` / `date` / `datetime` / `computed` fields whose value is a number, a boolean or a string that is not markup; a choice or reference field, whose value is a code or an id, shows `—` instead. The RecordView heading follows the same rule.
862
+
863
+ A single-line `text` value becomes a link when it is a URL, an email address, or a phone number — a Japanese number starting with 0 with 10 or 11 digits (`03-1234-5678`), or an international one starting with `+` with 8 to 15 digits. Postal codes (`100-0001`) and dates (`2026-09-28`) stay plain text.
864
+
845
865
  ## Design tokens
846
866
 
847
867
  All kit components use CSS custom properties with `--sfa-*` prefixes. Use these in your generated UI for visual consistency with SFA/CRM:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@geniee-inc/sfa-kit",
3
- "version": "1.18.0",
3
+ "version": "1.19.2",
4
4
  "description": "React hooks and components for building pages that connect to GENIEE SFA/CRM data.",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
@@ -11,16 +11,15 @@ Usage:
11
11
  ${TOKEN_ENV}=<token> npx sfa-kit-bundle \\
12
12
  --entry entry.tsx \\
13
13
  --out dist \\
14
- --studio-version 0.5.0 \\
15
14
  --api-base https://keystone-api-staging.chikyu.mobi
16
15
 
17
16
  Options:
18
17
  --entry Entry module that mounts the page into #root. Required.
19
18
  --out Output directory. Required.
20
- --studio-version The Studio build version this bundle targets, not the
21
- sfa-kit npm version. Required.
22
19
  --api-base Keystone API base, without a trailing slash. Required.
23
20
  --title <title> of the generated index.html. Default: SFA/CRM.
21
+ --studio-version Deprecated; omit it. When given, the bundle is signed in
22
+ the legacy format, over "{studio_version}:{content_hash}".
24
23
  --help Show this message.
25
24
 
26
25
  Environment:
@@ -28,7 +27,8 @@ Environment:
28
27
  API is called with. Required, and read from the environment rather than an
29
28
  argument so it does not land in shell history or in the process list.
30
29
 
31
- Writes index.html, assets/bundle.js and a signed manifest.json. Zip the
30
+ Writes index.html, assets/bundle.js and a signed manifest.json, which records
31
+ this sfa-kit version as sfa_kit_version. Zip the
32
32
  contents of the output directory — the files have to sit at the zip root.
33
33
  Needs esbuild installed alongside the kit.`;
34
34
  const OPTIONS = {
@@ -70,7 +70,7 @@ function parseCliOptions(argv) {
70
70
  */
71
71
  export function runBundleCli(argv, env, out) {
72
72
  return __awaiter(this, void 0, void 0, function* () {
73
- var _a, _b, _c, _d, _e, _f;
73
+ var _a, _b, _c, _d, _e;
74
74
  const parsed = parseCliOptions(argv);
75
75
  if (parsed.error != null) {
76
76
  out.error(`${parsed.error}\n\n${USAGE}`);
@@ -81,7 +81,7 @@ export function runBundleCli(argv, env, out) {
81
81
  out.log(USAGE);
82
82
  return 0;
83
83
  }
84
- const missing = ['entry', 'out', 'studio-version', 'api-base']
84
+ const missing = ['entry', 'out', 'api-base']
85
85
  .filter(name => { var _a; return ((_a = values[name]) !== null && _a !== void 0 ? _a : '') === ''; })
86
86
  .map(name => `--${name}`);
87
87
  const accessToken = (_a = env[TOKEN_ENV]) !== null && _a !== void 0 ? _a : '';
@@ -91,10 +91,18 @@ export function runBundleCli(argv, env, out) {
91
91
  out.error(`Missing required: ${missing.join(', ')}\n\n${USAGE}`);
92
92
  return 1;
93
93
  }
94
+ // NOTE: an empty value is refused, not read as "omitted". Omitting the flag
95
+ // switches the signing format, and a script whose variable came out empty
96
+ // should hear about it rather than quietly get the other format.
97
+ const studioVersion = values['studio-version'];
98
+ if (studioVersion != null && studioVersion.trim() === '') {
99
+ out.error(`--studio-version must not be empty. Omit it to sign content_hash alone.\n\n${USAGE}`);
100
+ return 1;
101
+ }
94
102
  try {
95
- const manifest = yield buildStaticBundle(Object.assign({ entry: (_b = values.entry) !== null && _b !== void 0 ? _b : '', outDir: (_c = values.out) !== null && _c !== void 0 ? _c : '', studioVersion: (_d = values['studio-version']) !== null && _d !== void 0 ? _d : '', apiBase: (_e = values['api-base']) !== null && _e !== void 0 ? _e : '', accessToken }, (((_f = values.title) !== null && _f !== void 0 ? _f : '') === '' ? {} : { title: values.title })));
103
+ const manifest = yield buildStaticBundle(Object.assign(Object.assign({ entry: (_b = values.entry) !== null && _b !== void 0 ? _b : '', outDir: (_c = values.out) !== null && _c !== void 0 ? _c : '', apiBase: (_d = values['api-base']) !== null && _d !== void 0 ? _d : '', accessToken }, (((_e = values.title) !== null && _e !== void 0 ? _e : '') === '' ? {} : { title: values.title })), (studioVersion == null ? {} : { studioVersion })));
96
104
  out.log(`Built and signed ${values.out} ` +
97
- `(studio_version ${manifest.studio_version}, ` +
105
+ `(sfa_kit_version ${manifest.sfa_kit_version}, ` +
98
106
  `content_hash ${manifest.content_hash}).`);
99
107
  return 0;
100
108
  }
package/src/bundle.d.ts CHANGED
@@ -13,7 +13,6 @@
13
13
  * await buildStaticBundle({
14
14
  * entry: 'entry.tsx',
15
15
  * outDir: 'dist',
16
- * studioVersion: '0.5.0',
17
16
  * apiBase: 'https://keystone-api-staging.chikyu.mobi',
18
17
  * accessToken: token,
19
18
  * });
@@ -21,6 +20,6 @@
21
20
  *
22
21
  * @packageDocumentation
23
22
  */
24
- export type { BundleManifest } from './lib/signing/index.js';
23
+ export type { BundleManifest, ContentHashManifest, } from './lib/signing/index.js';
25
24
  export type { BuildStaticBundleOptions } from './lib/bundle/index.js';
26
25
  export { buildStaticBundle } from './lib/bundle/index.js';
@@ -63,16 +63,13 @@ export function DevToolbar({ organizationId, preview, level, objectKey, onLogout
63
63
  };
64
64
  }, [client, hasObjectKey]);
65
65
  const [localRecordId, setLocalRecordId] = useState((_b = (_a = preview.state) === null || _a === void 0 ? void 0 : _a.record_id) !== null && _b !== void 0 ? _b : '');
66
- // NOTE: フォームは record レベル かつ objectKey があるときだけ描くため、
67
- // ここに来る時点で objectKey はある。型を絞るためのガードとして残す
66
+ // NOTE: フォームは record レベル かつ objectKey があるときだけ描く。対象の
67
+ // オブジェクトは StandaloneFlow が objectKey から決めるので、ここではレコード ID だけを渡す
68
68
  const handleSubmit = useCallback((e) => {
69
69
  e.preventDefault();
70
- if (!hasObjectKey || objectKey == null)
71
- return;
72
- preview.setObjectKey(objectKey);
73
70
  preview.setRecordId(localRecordId);
74
71
  preview.triggerFetch();
75
- }, [hasObjectKey, objectKey, localRecordId, preview]);
72
+ }, [localRecordId, preview]);
76
73
  // NOTE: カタログ取得前は key をそのまま表示名に使う。objectKey が無いページは
77
74
  // undefined のままにして、表示側で出し分ける
78
75
  const objectLabel = hasObjectKey
@@ -166,22 +166,31 @@ function StandaloneFlow({ apiBase, defaultOrganizationId, objectKey, level, chil
166
166
  // NOTE: level を渡さない既存ページのために、objectKey の有無から補う。
167
167
  // 以前は objectKey があること自体が「record レベル」を意味していた
168
168
  const resolvedLevel = level !== null && level !== void 0 ? level : (objectKey != null && objectKey !== '' ? 'record' : 'object');
169
- // Preview context state managed by DevToolbar.
170
- // When objectKey is provided, pre-populate it.
171
- const [previewObjectKey, setPreviewObjectKey] = useState(objectKey !== null && objectKey !== void 0 ? objectKey : '');
169
+ // Preview context state managed by DevToolbar. The object comes straight
170
+ // from the objectKey prop: nothing in the toolbar changes it.
172
171
  const [previewRecordId, setPreviewRecordId] = useState('');
172
+ // NOTE: a record id belongs to the object it was submitted for. objectKey can
173
+ // change without remounting this flow, so forget the id whenever it does —
174
+ // also when it changes back, or the old id would come back with it. This is
175
+ // React's "adjust state when a prop changes" pattern: it runs during render,
176
+ // so no frame pairs the new object with the old id
177
+ const [recordIdObjectKey, setRecordIdObjectKey] = useState(objectKey);
178
+ if (recordIdObjectKey !== objectKey) {
179
+ setRecordIdObjectKey(objectKey);
180
+ setPreviewRecordId('');
181
+ }
173
182
  const [fetchGeneration, setFetchGeneration] = useState(0);
174
183
  const triggerFetch = useCallback(() => {
175
184
  setFetchGeneration(prev => prev + 1);
176
185
  }, []);
177
186
  const previewControls = useMemo(() => ({
178
- state: previewObjectKey !== ''
179
- ? Object.assign({ object_key: previewObjectKey }, (previewRecordId !== '' ? { record_id: previewRecordId } : {})) : null,
180
- setObjectKey: setPreviewObjectKey,
187
+ state: objectKey == null || objectKey === ''
188
+ ? null
189
+ : Object.assign({ object_key: objectKey }, (previewRecordId === '' ? {} : { record_id: previewRecordId })),
181
190
  setRecordId: setPreviewRecordId,
182
191
  triggerFetch,
183
192
  fetchGeneration,
184
- }), [previewObjectKey, previewRecordId, triggerFetch, fetchGeneration]);
193
+ }), [objectKey, previewRecordId, triggerFetch, fetchGeneration]);
185
194
  const completeLogin = useCallback((newSession, normalizedBase) => {
186
195
  const sessionWithBase = Object.assign(Object.assign({}, newSession), { apiBase: normalizedBase });
187
196
  saveSession(sessionWithBase);
@@ -242,7 +251,7 @@ function StandaloneFlow({ apiBase, defaultOrganizationId, objectKey, level, chil
242
251
  if (!session || !auth) {
243
252
  return (_jsx(LoginForm, { defaultApiBase: apiBase, defaultOrganizationId: defaultOrganizationId, onSubmit: handleLogin, status: status, error: error }));
244
253
  }
245
- return (_jsx(SfaProvider, { apiBase: activeBase, auth: auth, mode: "direct", organizationId: session.organizationId, onAuthError: handleLogout, children: _jsxs(StandalonePreviewCtx.Provider, { value: previewControls, children: [_jsx(DevToolbar, { organizationId: session.organizationId, preview: previewControls, objectKey: objectKey, level: resolvedLevel, onLogout: handleLogout }), objectKey ? (_jsx(ObjectKeyGuard, { objectKey: objectKey, children: children })) : (children)] }) }));
254
+ return (_jsx(SfaProvider, { apiBase: activeBase, auth: auth, mode: "direct", organizationId: session.organizationId, onAuthError: handleLogout, children: _jsxs(StandalonePreviewCtx.Provider, { value: previewControls, children: [_jsx(DevToolbar, { organizationId: session.organizationId, preview: previewControls, objectKey: objectKey, level: resolvedLevel, onLogout: handleLogout }, objectKey), objectKey ? (_jsx(ObjectKeyGuard, { objectKey: objectKey, children: children })) : (children)] }) }));
246
255
  }
247
256
  // ---------------------------------------------------------------------------
248
257
  // GENIEE SFA/CRM-branded login form
@@ -1,5 +1,6 @@
1
1
  /**
2
- * @description State managed by the DevToolbar in standalone (non-embedded) mode.
2
+ * @description Preview state in standalone (non-embedded) mode. `object_key`
3
+ * is SfaLoginGate's `objectKey`; `record_id` is what the DevToolbar submits.
3
4
  * Mirrors the shape of EmbedContext so useEmbedContext() can fall back to it.
4
5
  */
5
6
  export interface StandalonePreviewState {
@@ -9,7 +10,6 @@ export interface StandalonePreviewState {
9
10
  }
10
11
  export interface StandalonePreviewControls {
11
12
  readonly state: StandalonePreviewState | null;
12
- readonly setObjectKey: (key: string) => void;
13
13
  readonly setRecordId: (id: string) => void;
14
14
  readonly triggerFetch: () => void;
15
15
  /** Increments on each triggerFetch call so consumers can react. */
@@ -17,7 +17,7 @@ export interface StandalonePreviewControls {
17
17
  }
18
18
  /**
19
19
  * @description React context carrying standalone preview state.
20
- * Populated only in standalone mode by StandaloneFlow's DevToolbar.
20
+ * Populated only in standalone mode by StandaloneFlow, for its DevToolbar.
21
21
  * useEmbedContext() checks this as a fallback when EmbedContext is null.
22
22
  */
23
23
  export declare const StandalonePreviewCtx: import("react").Context<StandalonePreviewControls | null>;
@@ -1,7 +1,7 @@
1
1
  import { createContext, useContext } from 'react';
2
2
  /**
3
3
  * @description React context carrying standalone preview state.
4
- * Populated only in standalone mode by StandaloneFlow's DevToolbar.
4
+ * Populated only in standalone mode by StandaloneFlow, for its DevToolbar.
5
5
  * useEmbedContext() checks this as a fallback when EmbedContext is null.
6
6
  */
7
7
  export const StandalonePreviewCtx = createContext(null);
@@ -10,7 +10,9 @@ export declare const EmbedContextValue: import("react").Context<EmbedContext | n
10
10
  * active runtime environment. Checks two sources in priority order:
11
11
  *
12
12
  * 1. Embed context (iframe postMessage flow) - set by the SFA/CRM host.
13
- * 2. Standalone preview context (DevToolbar) - set by the user in preview mode.
13
+ * 2. Standalone preview context - set by StandaloneFlow: `object_key` is
14
+ * SfaLoginGate's `objectKey`, `record_id` is what the user submits in the
15
+ * DevToolbar. It is null when no `objectKey` is passed.
14
16
  *
15
17
  * Returns null only when neither context is available.
16
18
  *
@@ -11,7 +11,9 @@ export const EmbedContextValue = createContext(null);
11
11
  * active runtime environment. Checks two sources in priority order:
12
12
  *
13
13
  * 1. Embed context (iframe postMessage flow) - set by the SFA/CRM host.
14
- * 2. Standalone preview context (DevToolbar) - set by the user in preview mode.
14
+ * 2. Standalone preview context - set by StandaloneFlow: `object_key` is
15
+ * SfaLoginGate's `objectKey`, `record_id` is what the user submits in the
16
+ * DevToolbar. It is null when no `objectKey` is passed.
15
17
  *
16
18
  * Returns null only when neither context is available.
17
19
  *
@@ -1,17 +1,18 @@
1
- import type { BundleManifest } from '../signing/types.js';
1
+ import type { BundleManifest, ContentHashManifest } from '../signing/types.js';
2
2
  export interface BuildStaticBundleOptions {
3
3
  /** Entry module that mounts the page, e.g. `entry.tsx`. */
4
4
  readonly entry: string;
5
5
  /** Directory the bundle is written to. Created if absent. */
6
6
  readonly outDir: string;
7
7
  /**
8
- * The Studio build version this bundle targets — not the sfa-kit npm version.
8
+ * @deprecated Omit it. The bundle is then signed over `content_hash` alone.
9
9
  *
10
- * NOTE: required on purpose. The signature covers
11
- * `"{studio_version}:{content_hash}"` and Keystone reads the field back
12
- * separately, so a default would sign the wrong version perfectly well.
10
+ * When given, the bundle is signed in the legacy format, over
11
+ * `"{studio_version}:{content_hash}"`, and the value is written to the
12
+ * manifest. Kept so that builds which still pass it keep producing a manifest
13
+ * with `studio_version`. Keystone accepts both formats.
13
14
  */
14
- readonly studioVersion: string;
15
+ readonly studioVersion?: string;
15
16
  /** Keystone API base, e.g. `https://keystone-api-staging.chikyu.mobi`. */
16
17
  readonly apiBase: string;
17
18
  /** The same Bearer token the rest of the Keystone API is called with. */
@@ -19,17 +20,32 @@ export interface BuildStaticBundleOptions {
19
20
  /** Title of the generated `index.html`. */
20
21
  readonly title?: string;
21
22
  /**
22
- * Extra manifest fields, e.g. `name` or `displayName`. Keystone ignores
23
- * everything but the three signed fields, which cannot be overridden here.
23
+ * Extra manifest fields, e.g. `name` or `displayName`. Keystone ignores them.
24
+ * The fields this builder writes (`content_hash`, `signature`,
25
+ * `sfa_kit_version` and `studio_version`) cannot be set here.
24
26
  */
25
27
  readonly manifestExtras?: Readonly<Record<string, unknown>>;
26
28
  }
29
+ /**
30
+ * @deprecated Omit `studioVersion`. Given one, the bundle is signed in the
31
+ * legacy format, over `"{studio_version}:{content_hash}"`, and the manifest
32
+ * carries `studio_version` beside `sfa_kit_version`. See the next overload for
33
+ * everything else this function does.
34
+ *
35
+ * NOTE: the order of the three overloads matters, since a call resolves to the
36
+ * first that matches: a `studioVersion` string here, none next, and one known
37
+ * only at run time (`string | undefined`) last.
38
+ */
39
+ export declare function buildStaticBundle(options: BuildStaticBundleOptions & {
40
+ readonly studioVersion: string;
41
+ }): Promise<BundleManifest & Pick<ContentHashManifest, 'sfa_kit_version'>>;
27
42
  /**
28
43
  * @description Builds the framework-free static bundle a page is uploaded as
29
- * to become a custom tab, and signs it.
44
+ * to become a custom tab, and signs it over `content_hash` alone.
30
45
  *
31
46
  * Produces `index.html`, `assets/bundle.js` and a signed `manifest.json` in
32
- * `outDir`. The caller zips that directory's contents — the files have to sit
47
+ * `outDir`. The manifest records this package's version as `sfa_kit_version`.
48
+ * The caller zips that directory's contents — the files have to sit
33
49
  * at the zip root. Zipping is left out because Node ships no zip writer, and a
34
50
  * dependency for it would reach a package whose runtime needs only `tslib`.
35
51
  *
@@ -42,4 +58,14 @@ export interface BuildStaticBundleOptions {
42
58
  *
43
59
  * @returns The manifest that was written.
44
60
  */
45
- export declare function buildStaticBundle({ entry, outDir, studioVersion, apiBase, accessToken, title, manifestExtras, }: BuildStaticBundleOptions): Promise<BundleManifest>;
61
+ export declare function buildStaticBundle(options: BuildStaticBundleOptions & {
62
+ readonly studioVersion?: undefined;
63
+ }): Promise<ContentHashManifest>;
64
+ /**
65
+ * For a `studioVersion` known only at run time (`string | undefined`), such as
66
+ * a forwarded command-line flag. The result is the legacy manifest when one is
67
+ * given and the current format otherwise, so `studio_version` reads as
68
+ * `string | undefined`. See the previous overload for everything else this
69
+ * function does.
70
+ */
71
+ export declare function buildStaticBundle(options: BuildStaticBundleOptions): Promise<ContentHashManifest | (BundleManifest & Pick<ContentHashManifest, 'sfa_kit_version'>)>;
@@ -3,8 +3,9 @@ import { mkdir, readFile, readdir, rm, stat, writeFile, } from 'node:fs/promises
3
3
  import { join, posix, relative } from 'node:path';
4
4
  import { computeContentHash } from '../signing/compute-content-hash.js';
5
5
  import { readBundleFiles } from '../signing/read-bundle-files.js';
6
+ import { SFA_KIT_VERSION } from '../version.js';
6
7
  import { loadEsbuild } from './esbuild-loader.js';
7
- import { requestSignature } from './request-signature.js';
8
+ import { isHexDigest, requestSignature } from './request-signature.js';
8
9
  /** Where the bundled script lands inside the output directory. */
9
10
  const BUNDLE_PATH = 'assets/bundle.js';
10
11
  /** esbuild writes this beside the script when the entry imports any CSS. */
@@ -31,29 +32,23 @@ const UNEXPECTED_SHOWN = 5;
31
32
  */
32
33
  const JUNK_FILES = new Set(['.DS_Store', 'Thumbs.db']);
33
34
  /**
34
- * @description Builds the framework-free static bundle a page is uploaded as
35
- * to become a custom tab, and signs it.
35
+ * Manifest keys this builder writes. A caller's `manifestExtras` never set them.
36
36
  *
37
- * Produces `index.html`, `assets/bundle.js` and a signed `manifest.json` in
38
- * `outDir`. The caller zips that directory's contents — the files have to sit
39
- * at the zip root. Zipping is left out because Node ships no zip writer, and a
40
- * dependency for it would reach a package whose runtime needs only `tslib`.
41
- *
42
- * Signing is not optional, and a failure removes the output: an unsigned bundle
43
- * uploads successfully while Keystone's verifier runs in `disabled` mode and
44
- * only starts failing at `enforced`, so a half-built directory left behind is
45
- * something a caller can still zip and upload by mistake.
46
- *
47
- * **Node.js only**, and needs `esbuild` installed alongside the kit.
48
- *
49
- * @returns The manifest that was written.
37
+ * NOTE: `studio_version` is listed even for builds that leave it out. Keystone
38
+ * checks a manifest carrying one in the legacy format, so an extra holding it
39
+ * would turn a correctly signed bundle into a 422.
50
40
  */
41
+ const OWN_MANIFEST_KEYS = new Set([
42
+ 'studio_version',
43
+ 'sfa_kit_version',
44
+ 'content_hash',
45
+ 'signature',
46
+ ]);
51
47
  export function buildStaticBundle(_a) {
52
48
  return __awaiter(this, arguments, void 0, function* ({ entry, outDir, studioVersion, apiBase, accessToken, title = 'SFA/CRM', manifestExtras = {}, }) {
53
49
  for (const [name, value] of [
54
50
  ['entry', entry],
55
51
  ['outDir', outDir],
56
- ['studioVersion', studioVersion],
57
52
  ['apiBase', apiBase],
58
53
  ['accessToken', accessToken],
59
54
  ]) {
@@ -66,6 +61,11 @@ export function buildStaticBundle(_a) {
66
61
  throw new Error(`${name} is required and must not be empty`);
67
62
  }
68
63
  }
64
+ // NOTE: refused rather than read as "omitted". The overloads promise a
65
+ // manifest carrying studio_version whenever a studioVersion is passed.
66
+ if (studioVersion != null && studioVersion.trim() === '') {
67
+ throw new Error('studioVersion must not be empty. Omit it to sign content_hash alone.');
68
+ }
69
69
  // Serialised now so a value that cannot be JSON — cyclic, a BigInt — fails
70
70
  // before anything is written, rather than after Keystone has signed.
71
71
  assertSerialisable(manifestExtras);
@@ -90,20 +90,27 @@ export function buildStaticBundle(_a) {
90
90
  // manifest.json is written last: the hash covers every other file, so the
91
91
  // directory has to be complete before it is computed.
92
92
  const contentHash = computeContentHash(yield readBundleFiles(outDir));
93
- const manifest = {
94
- studio_version: studioVersion,
95
- content_hash: contentHash,
96
- signature: yield requestSignature({
97
- apiBase,
98
- accessToken,
99
- studioVersion,
100
- contentHash,
101
- }),
102
- };
103
- yield writeFile(join(outDir, 'manifest.json'),
104
- // NOTE: the decorative fields go first so the three signed ones cannot be
105
- // overwritten by a caller's extras.
106
- `${JSON.stringify(Object.assign(Object.assign({}, manifestExtras), manifest), null, 2)}\n`, 'utf-8');
93
+ const signature = yield requestSignature({
94
+ apiBase,
95
+ accessToken,
96
+ studioVersion,
97
+ contentHash,
98
+ });
99
+ // NOTE: one object per format, not a conditional spread. The current
100
+ // format must not carry `studio_version` at all (see ContentHashManifest).
101
+ const manifest = studioVersion == null
102
+ ? {
103
+ sfa_kit_version: SFA_KIT_VERSION,
104
+ content_hash: contentHash,
105
+ signature,
106
+ }
107
+ : {
108
+ studio_version: studioVersion,
109
+ sfa_kit_version: SFA_KIT_VERSION,
110
+ content_hash: contentHash,
111
+ signature,
112
+ };
113
+ yield writeFile(join(outDir, 'manifest.json'), `${JSON.stringify(Object.assign(Object.assign({}, withoutOwnKeys(manifestExtras)), manifest), null, 2)}\n`, 'utf-8');
107
114
  return manifest;
108
115
  }
109
116
  catch (thrown) {
@@ -118,6 +125,16 @@ export function buildStaticBundle(_a) {
118
125
  }
119
126
  });
120
127
  }
128
+ /**
129
+ * Drops the keys this builder writes from a caller's extras.
130
+ *
131
+ * NOTE: spreading the extras first is not enough. A build without a
132
+ * studioVersion writes no `studio_version`, so one in the extras would survive
133
+ * the spread; {@link OWN_MANIFEST_KEYS} says why it must not reach the manifest.
134
+ */
135
+ function withoutOwnKeys(extras) {
136
+ return Object.fromEntries(Object.entries(extras).filter(([key]) => !OWN_MANIFEST_KEYS.has(key)));
137
+ }
121
138
  /** Fails now on a value `JSON.stringify` would reject after the bundle is signed. */
122
139
  function assertSerialisable(extras) {
123
140
  try {
@@ -140,8 +157,8 @@ function assertSerialisable(extras) {
140
157
  *
141
158
  * NOTE: matching those names is not enough to claim the directory. `index.html`
142
159
  * beside `assets/bundle.js` is also what a hand-written static site looks like,
143
- * and deleting one would be unrecoverable. A `manifest.json` carrying the three
144
- * signed fields is the proof, because only this builder writes one.
160
+ * and deleting one would be unrecoverable. The manifest is the proof; see
161
+ * {@link hasOwnManifest} for what counts.
145
162
  */
146
163
  function prepareOutDir(outDir) {
147
164
  return __awaiter(this, void 0, void 0, function* () {
@@ -205,7 +222,24 @@ function prepareOutDir(outDir) {
205
222
  yield removeOwnOutput(outDir);
206
223
  });
207
224
  }
208
- /** Whether the directory carries a manifest only this builder could have written. */
225
+ /**
226
+ * @description Whether the directory holds a previous build this builder may
227
+ * overwrite.
228
+ *
229
+ * NOTE: `content_hash` and `signature` alone are not proof. Any signed bundle
230
+ * carries them, and accepting one would let a rebuild delete another bundle's
231
+ * files. Each format also needs its version field: `sfa_kit_version` in the
232
+ * current format, `studio_version` in the legacy one (the condition earlier
233
+ * releases used).
234
+ *
235
+ * Neither field proves this builder wrote the manifest. GUIDE.md's hand-built
236
+ * flow writes `sfa_kit_version` too, and `createManifest` or a hand-run
237
+ * equivalent writes `studio_version`. Both identify a bundle built through
238
+ * sfa-kit, not this builder alone. Nothing in a manifest narrows it further,
239
+ * and refusing those would block rebuilding over existing output directories.
240
+ * What is lost is bounded: only the paths in `OWN_OUTPUT` are removed, and any
241
+ * other file stops the build first.
242
+ */
209
243
  function hasOwnManifest(outDir) {
210
244
  return __awaiter(this, void 0, void 0, function* () {
211
245
  const raw = yield readFile(join(outDir, 'manifest.json'), 'utf-8').catch(() => null);
@@ -213,13 +247,19 @@ function hasOwnManifest(outDir) {
213
247
  return false;
214
248
  try {
215
249
  const parsed = JSON.parse(raw);
216
- return ['studio_version', 'content_hash', 'signature'].every(field => typeof parsed[field] === 'string' && parsed[field] !== '');
250
+ const hasHexDigests = isHexDigest(parsed['content_hash']) && isHexDigest(parsed['signature']);
251
+ const hasVersionField = isNonEmptyString(parsed['sfa_kit_version']) ||
252
+ isNonEmptyString(parsed['studio_version']);
253
+ return hasHexDigests && hasVersionField;
217
254
  }
218
255
  catch (_a) {
219
256
  return false;
220
257
  }
221
258
  });
222
259
  }
260
+ function isNonEmptyString(value) {
261
+ return typeof value === 'string' && value !== '';
262
+ }
223
263
  /**
224
264
  * @description Deletes the files this builder writes, and nothing else.
225
265
  *