@geniee-inc/sfa-kit 1.18.0 → 1.19.0
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.
- package/GUIDE.md +33 -13
- package/package.json +1 -1
- package/src/bin/sfa-kit-bundle.js +16 -8
- package/src/bundle.d.ts +1 -2
- package/src/lib/bundle/build-static-bundle.d.ts +37 -11
- package/src/lib/bundle/build-static-bundle.js +75 -35
- package/src/lib/bundle/request-signature.d.ts +13 -1
- package/src/lib/bundle/request-signature.js +21 -6
- package/src/lib/components/FieldRenderer.d.ts +2 -1
- package/src/lib/components/FieldRenderer.js +32 -19
- package/src/lib/components/RecordList.js +10 -0
- package/src/lib/components/RecordView.js +27 -7
- package/src/lib/components/fieldDisplayText.d.ts +11 -0
- package/src/lib/components/fieldDisplayText.js +51 -0
- package/src/lib/hooks/useFieldTypes.d.ts +5 -0
- package/src/lib/hooks/useFieldTypes.js +5 -0
- package/src/lib/hooks/useObjectCatalog.d.ts +6 -0
- package/src/lib/hooks/useObjectCatalog.js +6 -0
- package/src/lib/hooks/useRecord.d.ts +20 -0
- package/src/lib/hooks/useRecord.js +53 -0
- package/src/lib/signing/compute-signature.d.ts +4 -2
- package/src/lib/signing/compute-signature.js +4 -2
- package/src/lib/signing/create-manifest.d.ts +8 -6
- package/src/lib/signing/create-manifest.js +8 -6
- package/src/lib/signing/index.d.ts +1 -1
- package/src/lib/signing/types.d.ts +33 -4
- package/src/lib/version.d.ts +1 -1
- package/src/lib/version.js +1 -1
- 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
|
|
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 {
|
|
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:
|
|
59
|
-
|
|
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
|
-
`
|
|
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).
|
|
@@ -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
|
@@ -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
|
|
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
|
|
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', '
|
|
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 : '',
|
|
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
|
-
`(
|
|
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';
|
|
@@ -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
|
-
*
|
|
8
|
+
* @deprecated Omit it. The bundle is then signed over `content_hash` alone.
|
|
9
9
|
*
|
|
10
|
-
*
|
|
11
|
-
* `"{studio_version}:{content_hash}"
|
|
12
|
-
*
|
|
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
|
|
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
|
-
*
|
|
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
|
|
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(
|
|
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
|
-
*
|
|
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
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
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
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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.
|
|
144
|
-
*
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
*
|
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether `value` has the shape Keystone gives `content_hash` and `signature`.
|
|
3
|
+
*
|
|
4
|
+
* NOTE: shared with the builder's previous-build check, so the shape the
|
|
5
|
+
* signer accepts and the shape a rebuild recognises cannot drift apart.
|
|
6
|
+
*/
|
|
7
|
+
export declare function isHexDigest(value: unknown): value is string;
|
|
1
8
|
/**
|
|
2
9
|
* @description Asks Keystone to sign a bundle, returning the signature.
|
|
3
10
|
*
|
|
@@ -6,6 +13,11 @@
|
|
|
6
13
|
* per project while the key must be identical across all of them (SFAJAI-186).
|
|
7
14
|
* The caller computes `contentHash` locally and Keystone signs it.
|
|
8
15
|
*
|
|
16
|
+
* Without `studioVersion`, Keystone signs `contentHash` alone. With it, Keystone
|
|
17
|
+
* returns the legacy signature over `"{studio_version}:{content_hash}"`, and the
|
|
18
|
+
* caller must write the same `studio_version` into the manifest for it to
|
|
19
|
+
* verify.
|
|
20
|
+
*
|
|
9
21
|
* NOTE: every failure throws. An unsigned bundle uploads successfully while
|
|
10
22
|
* Keystone's verifier runs in `disabled` mode, so returning "no signature" here
|
|
11
23
|
* would produce an artifact that looks fine and starts being rejected the day
|
|
@@ -17,6 +29,6 @@
|
|
|
17
29
|
export declare function requestSignature({ apiBase, accessToken, studioVersion, contentHash, }: {
|
|
18
30
|
readonly apiBase: string;
|
|
19
31
|
readonly accessToken: string;
|
|
20
|
-
readonly studioVersion
|
|
32
|
+
readonly studioVersion?: string;
|
|
21
33
|
readonly contentHash: string;
|
|
22
34
|
}): Promise<string>;
|
|
@@ -6,6 +6,15 @@ import { __awaiter } from "tslib";
|
|
|
6
6
|
const HEX_DIGEST_PATTERN = /^[0-9a-f]{64}$/;
|
|
7
7
|
/** Matches the timeout the documented Python flow uses for the same call. */
|
|
8
8
|
const SIGN_TIMEOUT_MS = 30000;
|
|
9
|
+
/**
|
|
10
|
+
* Whether `value` has the shape Keystone gives `content_hash` and `signature`.
|
|
11
|
+
*
|
|
12
|
+
* NOTE: shared with the builder's previous-build check, so the shape the
|
|
13
|
+
* signer accepts and the shape a rebuild recognises cannot drift apart.
|
|
14
|
+
*/
|
|
15
|
+
export function isHexDigest(value) {
|
|
16
|
+
return typeof value === 'string' && HEX_DIGEST_PATTERN.test(value);
|
|
17
|
+
}
|
|
9
18
|
/**
|
|
10
19
|
* @description Asks Keystone to sign a bundle, returning the signature.
|
|
11
20
|
*
|
|
@@ -14,6 +23,11 @@ const SIGN_TIMEOUT_MS = 30000;
|
|
|
14
23
|
* per project while the key must be identical across all of them (SFAJAI-186).
|
|
15
24
|
* The caller computes `contentHash` locally and Keystone signs it.
|
|
16
25
|
*
|
|
26
|
+
* Without `studioVersion`, Keystone signs `contentHash` alone. With it, Keystone
|
|
27
|
+
* returns the legacy signature over `"{studio_version}:{content_hash}"`, and the
|
|
28
|
+
* caller must write the same `studio_version` into the manifest for it to
|
|
29
|
+
* verify.
|
|
30
|
+
*
|
|
17
31
|
* NOTE: every failure throws. An unsigned bundle uploads successfully while
|
|
18
32
|
* Keystone's verifier runs in `disabled` mode, so returning "no signature" here
|
|
19
33
|
* would produce an artifact that looks fine and starts being rejected the day
|
|
@@ -24,7 +38,7 @@ const SIGN_TIMEOUT_MS = 30000;
|
|
|
24
38
|
*/
|
|
25
39
|
export function requestSignature(_a) {
|
|
26
40
|
return __awaiter(this, arguments, void 0, function* ({ apiBase, accessToken, studioVersion, contentHash, }) {
|
|
27
|
-
if (!
|
|
41
|
+
if (!isHexDigest(contentHash)) {
|
|
28
42
|
throw new Error(`content_hash must be a 64-character hex digest, got ${JSON.stringify(contentHash)}`);
|
|
29
43
|
}
|
|
30
44
|
// 末尾のスラッシュを落とす。付いたままだと //v1/pages/sign になり、多くの
|
|
@@ -44,10 +58,11 @@ export function requestSignature(_a) {
|
|
|
44
58
|
'Content-Type': 'application/json',
|
|
45
59
|
Authorization: `Bearer ${accessToken}`,
|
|
46
60
|
},
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
61
|
+
// NOTE: the field is left out, not sent empty. Keystone picks the format
|
|
62
|
+
// by whether studio_version is present, as it does for the manifest.
|
|
63
|
+
body: JSON.stringify(studioVersion == null
|
|
64
|
+
? { content_hash: contentHash }
|
|
65
|
+
: { studio_version: studioVersion, content_hash: contentHash }),
|
|
51
66
|
})
|
|
52
67
|
.catch((thrown) => {
|
|
53
68
|
throw new Error(`POST ${url} could not be reached ` +
|
|
@@ -72,7 +87,7 @@ export function requestSignature(_a) {
|
|
|
72
87
|
// error page echoed into this field would otherwise be written into
|
|
73
88
|
// manifest.json, upload fine while the verifier is `disabled`, and fail only
|
|
74
89
|
// once it is switched to `enforced`.
|
|
75
|
-
if (!
|
|
90
|
+
if (!isHexDigest(payload.signature)) {
|
|
76
91
|
throw new Error(`POST ${url} returned ${JSON.stringify(payload.signature)}, which is ` +
|
|
77
92
|
'not an HMAC-SHA-256 hex digest. The bundle was not signed.');
|
|
78
93
|
}
|
|
@@ -5,7 +5,8 @@ interface FieldRendererProps {
|
|
|
5
5
|
}
|
|
6
6
|
/**
|
|
7
7
|
* @description Renders a single field value based on its normalized kind.
|
|
8
|
-
* Uses `label` for display (pre-formatted by the API), falls back to
|
|
8
|
+
* Uses `label` for display (pre-formatted by the API), falls back to a plain
|
|
9
|
+
* readable `value` — never a code or an id.
|
|
9
10
|
*/
|
|
10
11
|
export declare function FieldRenderer({ fieldValue, fieldName }: FieldRendererProps): JSX.Element;
|
|
11
12
|
export {};
|
|
@@ -1,36 +1,28 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { toFieldDisplayText } from './fieldDisplayText';
|
|
2
3
|
/**
|
|
3
4
|
* @description Renders a single field value based on its normalized kind.
|
|
4
|
-
* Uses `label` for display (pre-formatted by the API), falls back to
|
|
5
|
+
* Uses `label` for display (pre-formatted by the API), falls back to a plain
|
|
6
|
+
* readable `value` — never a code or an id.
|
|
5
7
|
*/
|
|
6
8
|
export function FieldRenderer({ fieldValue, fieldName }) {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
+
const display = toFieldDisplayText(fieldValue);
|
|
10
|
+
// NOTE: every branch carries `data-field`, so a page can find a field by name
|
|
11
|
+
// whatever its value looks like — empty, multi-line or a link
|
|
9
12
|
if (display == null || display === '') {
|
|
10
|
-
return _jsx("span", { style: styles.empty, children: "\u2014" });
|
|
13
|
+
return (_jsx("span", { "data-field": fieldName, style: styles.empty, children: "\u2014" }));
|
|
11
14
|
}
|
|
12
15
|
if (fieldValue.kind === 'text' && typeof fieldValue.value === 'string') {
|
|
13
16
|
if (isMultiline(fieldValue.value)) {
|
|
14
|
-
return _jsx("span", { style: styles.preWrap, children: display });
|
|
17
|
+
return (_jsx("span", { "data-field": fieldName, style: styles.preWrap, children: display }));
|
|
15
18
|
}
|
|
16
19
|
const link = detectLink(fieldValue.value);
|
|
17
20
|
if (link) {
|
|
18
|
-
return (_jsx("a", { href: link.href, style: styles.link, target: "_blank", rel: "noopener noreferrer", children: display }));
|
|
21
|
+
return (_jsx("a", { "data-field": fieldName, href: link.href, style: styles.link, target: "_blank", rel: "noopener noreferrer", children: display }));
|
|
19
22
|
}
|
|
20
23
|
}
|
|
21
24
|
return _jsx("span", { "data-field": fieldName, children: display });
|
|
22
25
|
}
|
|
23
|
-
function formatFallback(field) {
|
|
24
|
-
if (field.value == null)
|
|
25
|
-
return null;
|
|
26
|
-
if (typeof field.value === 'number')
|
|
27
|
-
return field.value.toLocaleString('ja-JP');
|
|
28
|
-
if (typeof field.value === 'string')
|
|
29
|
-
return field.value;
|
|
30
|
-
if (Array.isArray(field.value))
|
|
31
|
-
return field.value.join(', ');
|
|
32
|
-
return String(field.value);
|
|
33
|
-
}
|
|
34
26
|
function isMultiline(value) {
|
|
35
27
|
return value.includes('\n');
|
|
36
28
|
}
|
|
@@ -39,10 +31,31 @@ function detectLink(value) {
|
|
|
39
31
|
return { href: value };
|
|
40
32
|
if (/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value))
|
|
41
33
|
return { href: `mailto:${value}` };
|
|
42
|
-
|
|
43
|
-
|
|
34
|
+
const phoneNumber = toPhoneNumber(value);
|
|
35
|
+
if (phoneNumber)
|
|
36
|
+
return { href: `tel:${phoneNumber}` };
|
|
44
37
|
return null;
|
|
45
38
|
}
|
|
39
|
+
/**
|
|
40
|
+
* The number to dial when `value` reads as a phone number, or null.
|
|
41
|
+
*
|
|
42
|
+
* NOTE: digits and dashes alone also make up postal codes (100-0001) and dates
|
|
43
|
+
* (2026-09-28). A Japanese number starts with a single 0 and has 10 or 11
|
|
44
|
+
* digits; an international one starts with + and has 8 to 15 (E.164 caps it at
|
|
45
|
+
* 15). A leading 00 is a zero-padded code, not a number to dial
|
|
46
|
+
*/
|
|
47
|
+
function toPhoneNumber(value) {
|
|
48
|
+
if (!/^(?:\+|\(?0)[\d\s().-]+$/.test(value))
|
|
49
|
+
return null;
|
|
50
|
+
const digits = value.replace(/\D/g, '');
|
|
51
|
+
if (value.startsWith('+')) {
|
|
52
|
+
return digits.length >= 8 && digits.length <= 15 ? `+${digits}` : null;
|
|
53
|
+
}
|
|
54
|
+
// NOTE: checked on the digits, so separators cannot hide the second 0
|
|
55
|
+
if (digits.startsWith('00'))
|
|
56
|
+
return null;
|
|
57
|
+
return digits.length === 10 || digits.length === 11 ? digits : null;
|
|
58
|
+
}
|
|
46
59
|
const styles = {
|
|
47
60
|
empty: {
|
|
48
61
|
color: '#94a3b8',
|
|
@@ -3,6 +3,7 @@ import { useEffect, useMemo, useRef } from 'react';
|
|
|
3
3
|
import { useRecordList } from '../hooks/useRecordList';
|
|
4
4
|
import { color, font, radius, space, baseContainerStyle, baseButtonStyle, } from '../theme';
|
|
5
5
|
import { SFA_KIT_VERSION } from '../version';
|
|
6
|
+
import { EmptyState } from './EmptyState';
|
|
6
7
|
import { FetchErrorState } from './FetchErrorState';
|
|
7
8
|
import { FieldRenderer } from './FieldRenderer';
|
|
8
9
|
const MAX_AUTO_COLUMNS = 8;
|
|
@@ -26,6 +27,15 @@ export function RecordList({ collection, columns, params, pageSize = 20, showPag
|
|
|
26
27
|
if (status === 'error') {
|
|
27
28
|
return (_jsxs("div", { style: styles.container, children: [_jsx(FetchErrorState, { message: errorMessage, onRetry: refetch }), _jsxs("div", { style: styles.versionFooter, children: ["@geniee-inc/sfa-kit v", SFA_KIT_VERSION] })] }));
|
|
28
29
|
}
|
|
30
|
+
// NOTE: on 'idle' nothing was asked — this component never disables the hook,
|
|
31
|
+
// so it only happens while `collection` is empty. "0 records" and "No records
|
|
32
|
+
// found" would state something the server never answered, and an empty table
|
|
33
|
+
// would look broken. Say what is missing instead — the input, not the data —
|
|
34
|
+
// as RecordView does for a missing record. From here on the status is
|
|
35
|
+
// 'loading' or 'success', so `!isLoading` means the items came from the server
|
|
36
|
+
if (status === 'idle') {
|
|
37
|
+
return (_jsxs("div", { style: styles.container, children: [_jsx(EmptyState, { message: "\u30AA\u30D6\u30B8\u30A7\u30AF\u30C8\u304C\u6307\u5B9A\u3055\u308C\u3066\u3044\u307E\u305B\u3093" }), _jsxs("div", { style: styles.versionFooter, children: ["@geniee-inc/sfa-kit v", SFA_KIT_VERSION] })] }));
|
|
38
|
+
}
|
|
29
39
|
const resolvedColumns = columns !== null && columns !== void 0 ? columns : autoDetectColumns(items);
|
|
30
40
|
const totalPages = Math.max(1, Math.ceil(totalCount / pageSize));
|
|
31
41
|
const rangeStart = pageIndex * pageSize + 1;
|
|
@@ -3,7 +3,9 @@ import { useEffect, useRef } from 'react';
|
|
|
3
3
|
import { useRecord } from '../hooks/useRecord';
|
|
4
4
|
import { color, font, radius, space, baseContainerStyle } from '../theme';
|
|
5
5
|
import { SFA_KIT_VERSION } from '../version';
|
|
6
|
+
import { EmptyState } from './EmptyState';
|
|
6
7
|
import { FetchErrorState } from './FetchErrorState';
|
|
8
|
+
import { toFieldDisplayText } from './fieldDisplayText';
|
|
7
9
|
import { FieldRenderer } from './FieldRenderer';
|
|
8
10
|
/**
|
|
9
11
|
* @description Fetches and displays a single SFA/CRM record in a label-value grid.
|
|
@@ -11,26 +13,41 @@ import { FieldRenderer } from './FieldRenderer';
|
|
|
11
13
|
export function RecordView({ collection, recordId, title, fields, fieldGroups, onError, errorMessage, }) {
|
|
12
14
|
var _a;
|
|
13
15
|
const { record, isLoading, error, refetch } = useRecord(collection, recordId);
|
|
16
|
+
// NOTE: useRecord reports an empty record id as an error so a page using the
|
|
17
|
+
// hook alone can say so, but to this component it is "no record specified",
|
|
18
|
+
// not a failed fetch: nothing was sent, and a retry would only repeat it.
|
|
19
|
+
// The screen and onError both read this, so a page never toasts a failure
|
|
20
|
+
// next to the "not specified" state
|
|
21
|
+
const fetchError = recordId === '' ? null : error;
|
|
14
22
|
// Use a ref so inline onError callbacks don't re-fire the effect on
|
|
15
23
|
// every parent render when error is present.
|
|
16
24
|
const onErrorRef = useRef(onError);
|
|
17
25
|
onErrorRef.current = onError;
|
|
18
26
|
useEffect(() => {
|
|
19
27
|
var _a;
|
|
20
|
-
if (
|
|
21
|
-
(_a = onErrorRef.current) === null || _a === void 0 ? void 0 : _a.call(onErrorRef,
|
|
22
|
-
}, [
|
|
23
|
-
if (
|
|
28
|
+
if (fetchError)
|
|
29
|
+
(_a = onErrorRef.current) === null || _a === void 0 ? void 0 : _a.call(onErrorRef, fetchError);
|
|
30
|
+
}, [fetchError]);
|
|
31
|
+
if (fetchError) {
|
|
24
32
|
// NOTE: `title` is the label the page already shows as the heading, so it
|
|
25
33
|
// names the record without exposing `collection` — the object key the API
|
|
26
34
|
// is addressed with. The derived heading is unavailable here: it reads the
|
|
27
35
|
// record, and on a failure there is none.
|
|
28
36
|
return (_jsxs("div", { style: styles.container, children: [_jsx(FetchErrorState, { message: errorMessage !== null && errorMessage !== void 0 ? errorMessage : (title ? `${title}の取得に失敗` : undefined), onRetry: refetch }), _jsxs("div", { style: styles.versionFooter, children: ["@geniee-inc/sfa-kit v", SFA_KIT_VERSION] })] }));
|
|
29
37
|
}
|
|
30
|
-
if (isLoading
|
|
38
|
+
if (isLoading) {
|
|
31
39
|
return (_jsxs("div", { style: styles.container, children: [_jsx("div", { style: styles.loading, children: "Loading..." }), _jsxs("div", { style: styles.versionFooter, children: ["@geniee-inc/sfa-kit v", SFA_KIT_VERSION] })] }));
|
|
32
40
|
}
|
|
33
|
-
|
|
41
|
+
// NOTE: nothing is loading and no fetch failed, yet there is no record. A
|
|
42
|
+
// fetch that finds none throws, so this means no record was specified — no
|
|
43
|
+
// collection or no record id yet. "Loading..." would never go away
|
|
44
|
+
if (!record) {
|
|
45
|
+
return (_jsxs("div", { style: styles.container, children: [_jsx(EmptyState, { message: "\u30EC\u30B3\u30FC\u30C9\u304C\u6307\u5B9A\u3055\u308C\u3066\u3044\u307E\u305B\u3093" }), _jsxs("div", { style: styles.versionFooter, children: ["@geniee-inc/sfa-kit v", SFA_KIT_VERSION] })] }));
|
|
46
|
+
}
|
|
47
|
+
// NOTE: an empty title from the API names nothing, so build one from
|
|
48
|
+
// `titleFields` as if it were missing
|
|
49
|
+
const recordTitle = record.title === '' ? null : record.title;
|
|
50
|
+
const displayTitle = (_a = title !== null && title !== void 0 ? title : recordTitle) !== null && _a !== void 0 ? _a : buildTitle(record.fields, record.titleFields);
|
|
34
51
|
const hasGroups = fieldGroups && fieldGroups.length > 0;
|
|
35
52
|
return (_jsxs("div", { style: styles.container, children: [displayTitle && _jsx("h2", { style: styles.title, children: displayTitle }), _jsxs("div", { style: styles.meta, children: [collection, " / ", record.record_id] }), hasGroups
|
|
36
53
|
? renderFieldGroups(record.fields, fieldGroups)
|
|
@@ -65,7 +82,10 @@ function renderFieldGroups(mapping, groups) {
|
|
|
65
82
|
}
|
|
66
83
|
function buildTitle(mapping, titleFields) {
|
|
67
84
|
return titleFields
|
|
68
|
-
.map(name => {
|
|
85
|
+
.map(name => {
|
|
86
|
+
const field = mapping[name];
|
|
87
|
+
return field ? toFieldDisplayText(field) : null;
|
|
88
|
+
})
|
|
69
89
|
.filter(Boolean)
|
|
70
90
|
.join(' ');
|
|
71
91
|
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { SfaFieldValue } from '../provider/types';
|
|
2
|
+
/**
|
|
3
|
+
* @description The text a field shows to a reader — its label, or a plain
|
|
4
|
+
* readable value when the label is empty or missing — or null.
|
|
5
|
+
*
|
|
6
|
+
* NOTE: never a code or an id. FieldRenderer and the heading RecordView builds
|
|
7
|
+
* from `titleFields` both use this, so a field shows the same thing in its row
|
|
8
|
+
* and in the heading, and a reference whose label is missing puts its raw id
|
|
9
|
+
* in neither
|
|
10
|
+
*/
|
|
11
|
+
export declare function toFieldDisplayText(field: SfaFieldValue): string | null;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Kinds whose `value` reads as it is. The others hold a code or an id, and
|
|
3
|
+
* only `label` names it
|
|
4
|
+
*/
|
|
5
|
+
const READABLE_VALUE_KINDS = new Set([
|
|
6
|
+
'text',
|
|
7
|
+
'number',
|
|
8
|
+
'date',
|
|
9
|
+
'datetime',
|
|
10
|
+
'computed',
|
|
11
|
+
]);
|
|
12
|
+
/**
|
|
13
|
+
* Whether `value` can be shown to a reader as it is: a number, a boolean, or a
|
|
14
|
+
* string that is not markup
|
|
15
|
+
*
|
|
16
|
+
* NOTE: Chikyu also sends kinds it has no mapping for as 'text' — an image
|
|
17
|
+
* field holds HTML, others may hold an object — so the kind alone does not
|
|
18
|
+
* promise readable text
|
|
19
|
+
*/
|
|
20
|
+
function isPlainValue(value) {
|
|
21
|
+
if (typeof value === 'number' || typeof value === 'boolean')
|
|
22
|
+
return true;
|
|
23
|
+
return typeof value === 'string' && !/<[a-z!/][^>]*>/i.test(value);
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* @description The text a field shows to a reader — its label, or a plain
|
|
27
|
+
* readable value when the label is empty or missing — or null.
|
|
28
|
+
*
|
|
29
|
+
* NOTE: never a code or an id. FieldRenderer and the heading RecordView builds
|
|
30
|
+
* from `titleFields` both use this, so a field shows the same thing in its row
|
|
31
|
+
* and in the heading, and a reference whose label is missing puts its raw id
|
|
32
|
+
* in neither
|
|
33
|
+
*/
|
|
34
|
+
export function toFieldDisplayText(field) {
|
|
35
|
+
if (field.label != null && field.label !== '')
|
|
36
|
+
return field.label;
|
|
37
|
+
// NOTE: an empty or missing label carries no formatting, so a plain readable
|
|
38
|
+
// value shows instead. Anything else stays empty: a choice or a reference
|
|
39
|
+
// holds a code or an id (Chikyu leaves the caption out of fields it does not
|
|
40
|
+
// display, and a master-backed select holds an id), and markup or an object
|
|
41
|
+
// means nothing to a reader
|
|
42
|
+
return READABLE_VALUE_KINDS.has(field.kind) && isPlainValue(field.value)
|
|
43
|
+
? formatPlainValue(field.value)
|
|
44
|
+
: null;
|
|
45
|
+
}
|
|
46
|
+
/** A plain value as text: a number formatted for ja-JP, the rest as it is */
|
|
47
|
+
function formatPlainValue(value) {
|
|
48
|
+
return typeof value === 'number'
|
|
49
|
+
? value.toLocaleString('ja-JP')
|
|
50
|
+
: String(value);
|
|
51
|
+
}
|
|
@@ -10,6 +10,11 @@ interface UseFieldTypesResult {
|
|
|
10
10
|
}
|
|
11
11
|
/**
|
|
12
12
|
* @description Fetches the static field-type catalog from Keystone.
|
|
13
|
+
*
|
|
14
|
+
* NOTE: a failed refetch keeps the field types already shown and sets `error`
|
|
15
|
+
* beside them. The catalog almost never changes, so a transient failure must
|
|
16
|
+
* not empty the pickers built from it. useRecord drops its record instead: a
|
|
17
|
+
* record does change, and a stale one would pass for the current one.
|
|
13
18
|
*/
|
|
14
19
|
export declare function useFieldTypes(options?: UseFieldTypesOptions): UseFieldTypesResult;
|
|
15
20
|
export {};
|
|
@@ -2,6 +2,11 @@ import { useCallback, useEffect, useRef, useState } from 'react';
|
|
|
2
2
|
import { useSfaClient } from '../provider/useSfaClient';
|
|
3
3
|
/**
|
|
4
4
|
* @description Fetches the static field-type catalog from Keystone.
|
|
5
|
+
*
|
|
6
|
+
* NOTE: a failed refetch keeps the field types already shown and sets `error`
|
|
7
|
+
* beside them. The catalog almost never changes, so a transient failure must
|
|
8
|
+
* not empty the pickers built from it. useRecord drops its record instead: a
|
|
9
|
+
* record does change, and a stale one would pass for the current one.
|
|
5
10
|
*/
|
|
6
11
|
export function useFieldTypes(options) {
|
|
7
12
|
var _a;
|
|
@@ -10,6 +10,12 @@ interface UseObjectCatalogResult {
|
|
|
10
10
|
}
|
|
11
11
|
/**
|
|
12
12
|
* @description Fetches the list of all objects available in the org.
|
|
13
|
+
*
|
|
14
|
+
* NOTE: a failed refetch keeps the objects already shown and sets `error`
|
|
15
|
+
* beside them. The catalog almost never changes, so a transient failure must
|
|
16
|
+
* not empty the object picker built from it. useRecord drops its record
|
|
17
|
+
* instead: a record does change, and a stale one would pass for the current
|
|
18
|
+
* one.
|
|
13
19
|
*/
|
|
14
20
|
export declare function useObjectCatalog(options?: UseObjectCatalogOptions): UseObjectCatalogResult;
|
|
15
21
|
export {};
|
|
@@ -2,6 +2,12 @@ import { useCallback, useEffect, useRef, useState } from 'react';
|
|
|
2
2
|
import { useSfaClient } from '../provider/useSfaClient';
|
|
3
3
|
/**
|
|
4
4
|
* @description Fetches the list of all objects available in the org.
|
|
5
|
+
*
|
|
6
|
+
* NOTE: a failed refetch keeps the objects already shown and sets `error`
|
|
7
|
+
* beside them. The catalog almost never changes, so a transient failure must
|
|
8
|
+
* not empty the object picker built from it. useRecord drops its record
|
|
9
|
+
* instead: a record does change, and a stale one would pass for the current
|
|
10
|
+
* one.
|
|
5
11
|
*/
|
|
6
12
|
export function useObjectCatalog(options) {
|
|
7
13
|
var _a;
|
|
@@ -10,6 +10,26 @@ interface UseRecordResult {
|
|
|
10
10
|
}
|
|
11
11
|
/**
|
|
12
12
|
* @description Fetches a single record from the SFA/CRM API.
|
|
13
|
+
*
|
|
14
|
+
* NOTE: a failed refetch drops the record already shown. A record changes, so
|
|
15
|
+
* an earlier copy next to the error would pass for the current one and could
|
|
16
|
+
* be edited from. The catalog hooks keep their data on a failure instead,
|
|
17
|
+
* because what they hold almost never changes.
|
|
18
|
+
*
|
|
19
|
+
* NOTE: an empty `collection` means there is nothing to ask yet — the embed
|
|
20
|
+
* context has not arrived, say — and is treated like `enabled: false`: no
|
|
21
|
+
* request and no error. An error would flash on every page that is still
|
|
22
|
+
* waiting, and useRecordList and useDescribe stay idle the same way. An empty
|
|
23
|
+
* `recordId` with a collection given is different, and does set `error`: the
|
|
24
|
+
* object is known but no record is picked, which the message asks the reader to
|
|
25
|
+
* fix, and an empty id that reached the API was answered with a 503.
|
|
26
|
+
*
|
|
27
|
+
* NOTE: the result always describes the `collection` / `recordId` / client
|
|
28
|
+
* passed right now. When they change, the state still answers the old ones
|
|
29
|
+
* until the fetch for the new ones starts, so it reports no record and no
|
|
30
|
+
* error — with `isLoading` when a fetch is due — instead of the old answer,
|
|
31
|
+
* the way useRecordList reports 'loading' before its fetch settles. Disabling
|
|
32
|
+
* without changing them keeps the record already fetched.
|
|
13
33
|
*/
|
|
14
34
|
export declare function useRecord(collection: string, recordId: string, options?: UseRecordOptions): UseRecordResult;
|
|
15
35
|
export {};
|
|
@@ -4,6 +4,26 @@ import { ERR_EMPTY_RECORD_ID } from '../errors';
|
|
|
4
4
|
import { useSfaClient } from '../provider/useSfaClient';
|
|
5
5
|
/**
|
|
6
6
|
* @description Fetches a single record from the SFA/CRM API.
|
|
7
|
+
*
|
|
8
|
+
* NOTE: a failed refetch drops the record already shown. A record changes, so
|
|
9
|
+
* an earlier copy next to the error would pass for the current one and could
|
|
10
|
+
* be edited from. The catalog hooks keep their data on a failure instead,
|
|
11
|
+
* because what they hold almost never changes.
|
|
12
|
+
*
|
|
13
|
+
* NOTE: an empty `collection` means there is nothing to ask yet — the embed
|
|
14
|
+
* context has not arrived, say — and is treated like `enabled: false`: no
|
|
15
|
+
* request and no error. An error would flash on every page that is still
|
|
16
|
+
* waiting, and useRecordList and useDescribe stay idle the same way. An empty
|
|
17
|
+
* `recordId` with a collection given is different, and does set `error`: the
|
|
18
|
+
* object is known but no record is picked, which the message asks the reader to
|
|
19
|
+
* fix, and an empty id that reached the API was answered with a 503.
|
|
20
|
+
*
|
|
21
|
+
* NOTE: the result always describes the `collection` / `recordId` / client
|
|
22
|
+
* passed right now. When they change, the state still answers the old ones
|
|
23
|
+
* until the fetch for the new ones starts, so it reports no record and no
|
|
24
|
+
* error — with `isLoading` when a fetch is due — instead of the old answer,
|
|
25
|
+
* the way useRecordList reports 'loading' before its fetch settles. Disabling
|
|
26
|
+
* without changing them keeps the record already fetched.
|
|
7
27
|
*/
|
|
8
28
|
export function useRecord(collection, recordId, options) {
|
|
9
29
|
var _a;
|
|
@@ -13,9 +33,19 @@ export function useRecord(collection, recordId, options) {
|
|
|
13
33
|
const [isLoading, setIsLoading] = useState(enabled && !!collection && !!recordId);
|
|
14
34
|
const [error, setError] = useState(null);
|
|
15
35
|
const fetchId = useRef(0);
|
|
36
|
+
// NOTE: which inputs the state above answers. Set whenever doFetch starts
|
|
37
|
+
// answering a new question, so a render can tell the state is about inputs
|
|
38
|
+
// that are no longer passed
|
|
39
|
+
const answeredRef = useRef(null);
|
|
16
40
|
const doFetch = useCallback(() => __awaiter(this, void 0, void 0, function* () {
|
|
17
41
|
if (!enabled || !collection)
|
|
18
42
|
return;
|
|
43
|
+
const answered = answeredRef.current;
|
|
44
|
+
const isNewQuestion = answered == null ||
|
|
45
|
+
answered.client !== client ||
|
|
46
|
+
answered.collection !== collection ||
|
|
47
|
+
answered.recordId !== recordId;
|
|
48
|
+
answeredRef.current = { client, collection, recordId };
|
|
19
49
|
if (!recordId) {
|
|
20
50
|
fetchId.current += 1;
|
|
21
51
|
setRecord(null);
|
|
@@ -25,6 +55,10 @@ export function useRecord(collection, recordId, options) {
|
|
|
25
55
|
}
|
|
26
56
|
fetchId.current += 1;
|
|
27
57
|
const currentFetchId = fetchId.current;
|
|
58
|
+
// NOTE: a refetch of the same record keeps it on screen while it loads; a
|
|
59
|
+
// record answering other inputs must not stand in for the new one
|
|
60
|
+
if (isNewQuestion)
|
|
61
|
+
setRecord(null);
|
|
28
62
|
setIsLoading(true);
|
|
29
63
|
setError(null);
|
|
30
64
|
try {
|
|
@@ -45,5 +79,24 @@ export function useRecord(collection, recordId, options) {
|
|
|
45
79
|
useEffect(() => {
|
|
46
80
|
doFetch();
|
|
47
81
|
}, [doFetch]);
|
|
82
|
+
const answered = answeredRef.current;
|
|
83
|
+
const answersCurrentInputs = answered != null &&
|
|
84
|
+
answered.client === client &&
|
|
85
|
+
answered.collection === collection &&
|
|
86
|
+
answered.recordId === recordId;
|
|
87
|
+
// NOTE: the state answers other inputs — the fetch for these has not started
|
|
88
|
+
// yet, or nothing will be asked (no collection, or disabled after the inputs
|
|
89
|
+
// changed). Either way the old record and error answer something else. It
|
|
90
|
+
// is loading only when a fetch is due; an empty record id gets its error from
|
|
91
|
+
// the effect. Disabling without changing the inputs keeps what was fetched,
|
|
92
|
+
// as useRecordList does
|
|
93
|
+
if (!answersCurrentInputs) {
|
|
94
|
+
return {
|
|
95
|
+
record: null,
|
|
96
|
+
isLoading: enabled && !!collection && !!recordId,
|
|
97
|
+
error: null,
|
|
98
|
+
refetch: doFetch,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
48
101
|
return { record, isLoading, error, refetch: doFetch };
|
|
49
102
|
}
|
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
* Computes the HMAC-SHA-256 bundle signature matching Keystone's
|
|
3
3
|
* BundleSignatureVerifier.
|
|
4
4
|
*
|
|
5
|
-
* Signs the message `"{studioVersion}:{contentHash}"` with the given key.
|
|
5
|
+
* Signs the message `"{studioVersion}:{contentHash}"` with the given key. This
|
|
6
|
+
* is the legacy format; a manifest without `studio_version` is checked
|
|
7
|
+
* against `contentHash` alone (see `ContentHashManifest`).
|
|
6
8
|
*
|
|
7
9
|
* **The signing key lives only in Keystone (`KEYSTONE_STUDIO__SIGNING_KEY`),
|
|
8
10
|
* so a page build cannot call this.** Deploying the key to Studio as well was
|
|
@@ -13,7 +15,7 @@
|
|
|
13
15
|
* ```ts
|
|
14
16
|
* const files = await readBundleFiles('dist');
|
|
15
17
|
* const contentHash = computeContentHash(files);
|
|
16
|
-
* // POST /v1/pages/sign {
|
|
18
|
+
* // POST /v1/pages/sign { content_hash } -> { signature }
|
|
17
19
|
* ```
|
|
18
20
|
*
|
|
19
21
|
* Reach for this only from a service that legitimately holds the key. Signing
|
|
@@ -3,7 +3,9 @@ import { createHmac } from 'node:crypto';
|
|
|
3
3
|
* Computes the HMAC-SHA-256 bundle signature matching Keystone's
|
|
4
4
|
* BundleSignatureVerifier.
|
|
5
5
|
*
|
|
6
|
-
* Signs the message `"{studioVersion}:{contentHash}"` with the given key.
|
|
6
|
+
* Signs the message `"{studioVersion}:{contentHash}"` with the given key. This
|
|
7
|
+
* is the legacy format; a manifest without `studio_version` is checked
|
|
8
|
+
* against `contentHash` alone (see `ContentHashManifest`).
|
|
7
9
|
*
|
|
8
10
|
* **The signing key lives only in Keystone (`KEYSTONE_STUDIO__SIGNING_KEY`),
|
|
9
11
|
* so a page build cannot call this.** Deploying the key to Studio as well was
|
|
@@ -14,7 +16,7 @@ import { createHmac } from 'node:crypto';
|
|
|
14
16
|
* ```ts
|
|
15
17
|
* const files = await readBundleFiles('dist');
|
|
16
18
|
* const contentHash = computeContentHash(files);
|
|
17
|
-
* // POST /v1/pages/sign {
|
|
19
|
+
* // POST /v1/pages/sign { content_hash } -> { signature }
|
|
18
20
|
* ```
|
|
19
21
|
*
|
|
20
22
|
* Reach for this only from a service that legitimately holds the key. Signing
|
|
@@ -2,15 +2,17 @@ import type { BundleManifest, FileEntry } from './types.js';
|
|
|
2
2
|
/**
|
|
3
3
|
* Creates a signed {@link BundleManifest} from a list of bundle files.
|
|
4
4
|
*
|
|
5
|
-
* Returns the three fields
|
|
6
|
-
* Callers can spread decorative fields
|
|
7
|
-
*
|
|
5
|
+
* Returns the three fields of the legacy format, signed over
|
|
6
|
+
* `"{studio_version}:{content_hash}"`. Callers can spread decorative fields
|
|
7
|
+
* (e.g. `name`, `displayName`) on top before writing to disk.
|
|
8
8
|
*
|
|
9
9
|
* **Requires the shared signing key, which page builds do not have** — see
|
|
10
10
|
* {@link computeSignature}. A page build computes `content_hash` with
|
|
11
|
-
* {@link computeContentHash}
|
|
12
|
-
* `POST /v1/pages/sign`,
|
|
13
|
-
*
|
|
11
|
+
* {@link computeContentHash}, posts it alone to Keystone's
|
|
12
|
+
* `POST /v1/pages/sign`, and writes the current format, a
|
|
13
|
+
* `ContentHashManifest` (see GUIDE.md). It does not write `studio_version`:
|
|
14
|
+
* Keystone checks a manifest carrying one in this legacy format, so a signature
|
|
15
|
+
* over `content_hash` alone would not verify.
|
|
14
16
|
*
|
|
15
17
|
* @param studioVersion - Studio version stored as page metadata.
|
|
16
18
|
* @param files - All files in the bundle (manifest.json is automatically excluded).
|
|
@@ -3,15 +3,17 @@ import { computeSignature } from './compute-signature.js';
|
|
|
3
3
|
/**
|
|
4
4
|
* Creates a signed {@link BundleManifest} from a list of bundle files.
|
|
5
5
|
*
|
|
6
|
-
* Returns the three fields
|
|
7
|
-
* Callers can spread decorative fields
|
|
8
|
-
*
|
|
6
|
+
* Returns the three fields of the legacy format, signed over
|
|
7
|
+
* `"{studio_version}:{content_hash}"`. Callers can spread decorative fields
|
|
8
|
+
* (e.g. `name`, `displayName`) on top before writing to disk.
|
|
9
9
|
*
|
|
10
10
|
* **Requires the shared signing key, which page builds do not have** — see
|
|
11
11
|
* {@link computeSignature}. A page build computes `content_hash` with
|
|
12
|
-
* {@link computeContentHash}
|
|
13
|
-
* `POST /v1/pages/sign`,
|
|
14
|
-
*
|
|
12
|
+
* {@link computeContentHash}, posts it alone to Keystone's
|
|
13
|
+
* `POST /v1/pages/sign`, and writes the current format, a
|
|
14
|
+
* `ContentHashManifest` (see GUIDE.md). It does not write `studio_version`:
|
|
15
|
+
* Keystone checks a manifest carrying one in this legacy format, so a signature
|
|
16
|
+
* over `content_hash` alone would not verify.
|
|
15
17
|
*
|
|
16
18
|
* @param studioVersion - Studio version stored as page metadata.
|
|
17
19
|
* @param files - All files in the bundle (manifest.json is automatically excluded).
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export type { BundleManifest, FileEntry } from './types.js';
|
|
1
|
+
export type { BundleManifest, FileEntry, ContentHashManifest, } from './types.js';
|
|
2
2
|
export { computeContentHash } from './compute-content-hash.js';
|
|
3
3
|
export { readBundleFiles } from './read-bundle-files.js';
|
|
4
4
|
export { computeSignature } from './compute-signature.js';
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
2
|
+
* The legacy manifest format: the signature covers
|
|
3
|
+
* `"{studio_version}:{content_hash}"`. Keystone still accepts it, and
|
|
4
|
+
* {@link createManifest} and `buildStaticBundle` given a `studioVersion` still
|
|
5
|
+
* produce it. Callers can spread decorative fields on top before writing to
|
|
6
|
+
* disk (e.g. `name`, `displayName`, `entry`).
|
|
6
7
|
*/
|
|
7
8
|
export interface BundleManifest {
|
|
8
9
|
/** Studio version string (the build version, not the sfa-kit npm version). */
|
|
@@ -12,6 +13,34 @@ export interface BundleManifest {
|
|
|
12
13
|
/** HMAC-SHA-256 hex of `"{studio_version}:{content_hash}"`. */
|
|
13
14
|
signature: string;
|
|
14
15
|
}
|
|
16
|
+
/**
|
|
17
|
+
* The manifest `buildStaticBundle` writes when no `studioVersion` is given.
|
|
18
|
+
*
|
|
19
|
+
* Keystone reads a manifest without `studio_version` as this format and checks
|
|
20
|
+
* the signature against `content_hash` alone. `content_hash` covers every
|
|
21
|
+
* other file in the bundle, so that is what makes an edited bundle fail.
|
|
22
|
+
*/
|
|
23
|
+
export interface ContentHashManifest {
|
|
24
|
+
/**
|
|
25
|
+
* The sfa-kit version that ran the build. Not signed: Keystone stores it for
|
|
26
|
+
* troubleshooting only.
|
|
27
|
+
*
|
|
28
|
+
* NOTE: this is the copy of sfa-kit that ran the bundler. esbuild bundles
|
|
29
|
+
* whichever copy the entry module imports, which is normally the same one,
|
|
30
|
+
* but a project with two installed copies can bundle the other.
|
|
31
|
+
*/
|
|
32
|
+
sfa_kit_version: string;
|
|
33
|
+
/** SHA-256 hex digest of all non-manifest file contents. */
|
|
34
|
+
content_hash: string;
|
|
35
|
+
/** HMAC-SHA-256 hex of `content_hash`. */
|
|
36
|
+
signature: string;
|
|
37
|
+
/**
|
|
38
|
+
* Never present in this format. Keystone picks the format by whether the
|
|
39
|
+
* manifest carries `studio_version`, so one that does is a legacy
|
|
40
|
+
* {@link BundleManifest}, not this.
|
|
41
|
+
*/
|
|
42
|
+
studio_version?: never;
|
|
43
|
+
}
|
|
15
44
|
/** A single file in the bundle, used for content hash computation. */
|
|
16
45
|
export interface FileEntry {
|
|
17
46
|
/** Relative path using forward slashes (e.g. "index.html", "assets/bundle.js"). */
|
package/src/lib/version.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const SFA_KIT_VERSION = "1.
|
|
1
|
+
export declare const SFA_KIT_VERSION = "1.19.0";
|
package/src/lib/version.js
CHANGED
package/src/signing.d.ts
CHANGED
|
@@ -14,10 +14,10 @@
|
|
|
14
14
|
* import { readBundleFiles, computeContentHash } from '@geniee-inc/sfa-kit/signing';
|
|
15
15
|
*
|
|
16
16
|
* const contentHash = computeContentHash(await readBundleFiles('./dist'));
|
|
17
|
-
* // POST /v1/pages/sign {
|
|
17
|
+
* // POST /v1/pages/sign { content_hash } -> { signature }
|
|
18
18
|
* ```
|
|
19
19
|
*
|
|
20
20
|
* @packageDocumentation
|
|
21
21
|
*/
|
|
22
|
-
export type { BundleManifest, FileEntry } from './lib/signing/index.js';
|
|
22
|
+
export type { BundleManifest, FileEntry, ContentHashManifest, } from './lib/signing/index.js';
|
|
23
23
|
export { computeContentHash, readBundleFiles, computeSignature, createManifest, createManifestFromDirectory, } from './lib/signing/index.js';
|