@geniee-inc/sfa-kit 1.16.0 → 1.18.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 +92 -2
- package/package.json +19 -2
- package/src/bin/sfa-kit-bundle.d.ts +13 -0
- package/src/bin/sfa-kit-bundle.js +142 -0
- package/src/bundle.d.ts +26 -0
- package/src/bundle.js +1 -0
- package/src/index.d.ts +1 -1
- package/src/lib/auth/resolve-api-base.d.ts +6 -4
- package/src/lib/auth/resolve-api-base.js +24 -5
- package/src/lib/auth/useEmbedSession.d.ts +2 -1
- package/src/lib/auth/useEmbedSession.js +196 -26
- package/src/lib/bundle/build-static-bundle.d.ts +45 -0
- package/src/lib/bundle/build-static-bundle.js +263 -0
- package/src/lib/bundle/esbuild-loader.d.ts +25 -0
- package/src/lib/bundle/esbuild-loader.js +27 -0
- package/src/lib/bundle/index.d.ts +2 -0
- package/src/lib/bundle/index.js +1 -0
- package/src/lib/bundle/request-signature.d.ts +22 -0
- package/src/lib/bundle/request-signature.js +81 -0
- package/src/lib/provider/SfaClient.d.ts +13 -0
- package/src/lib/provider/SfaClient.js +25 -8
- package/src/lib/provider/SfaProvider.d.ts +2 -2
- package/src/lib/provider/SfaProvider.js +1 -1
- package/src/lib/provider/types.d.ts +12 -1
- package/src/lib/signing/compute-content-hash.d.ts +1 -1
- package/src/lib/signing/compute-signature.d.ts +17 -0
- package/src/lib/signing/compute-signature.js +17 -0
- package/src/lib/signing/create-manifest-from-directory.d.ts +13 -3
- package/src/lib/signing/create-manifest-from-directory.js +15 -28
- package/src/lib/signing/create-manifest.d.ts +7 -1
- package/src/lib/signing/create-manifest.js +8 -2
- package/src/lib/signing/index.d.ts +6 -5
- package/src/lib/signing/index.js +5 -4
- package/src/lib/signing/read-bundle-files.d.ts +19 -0
- package/src/lib/signing/read-bundle-files.js +41 -0
- package/src/lib/version.d.ts +1 -1
- package/src/lib/version.js +1 -1
- package/src/signing.d.ts +9 -4
- package/src/signing.js +1 -1
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/** The slice of esbuild's API the bundler uses. */
|
|
2
|
+
export interface EsbuildModule {
|
|
3
|
+
build(options: {
|
|
4
|
+
entryPoints: string[];
|
|
5
|
+
outfile: string;
|
|
6
|
+
bundle: boolean;
|
|
7
|
+
minify: boolean;
|
|
8
|
+
format: 'esm';
|
|
9
|
+
jsx: 'automatic';
|
|
10
|
+
target: string;
|
|
11
|
+
}): Promise<unknown>;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* @description Loads esbuild at call time.
|
|
15
|
+
*
|
|
16
|
+
* esbuild is an optional peer: it is a build-time tool for the one caller that
|
|
17
|
+
* packages a static bundle, and adding it to `dependencies` would put a
|
|
18
|
+
* platform-specific binary into every install of a package whose runtime needs
|
|
19
|
+
* nothing but `tslib`.
|
|
20
|
+
*
|
|
21
|
+
* NOTE: the specifier goes through a variable so TypeScript does not resolve
|
|
22
|
+
* `esbuild` at compile time. Naming it directly would make the kit fail to
|
|
23
|
+
* build wherever esbuild is absent — which is everywhere it is not being used.
|
|
24
|
+
*/
|
|
25
|
+
export declare function loadEsbuild(): Promise<EsbuildModule>;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { __awaiter } from "tslib";
|
|
2
|
+
/**
|
|
3
|
+
* @description Loads esbuild at call time.
|
|
4
|
+
*
|
|
5
|
+
* esbuild is an optional peer: it is a build-time tool for the one caller that
|
|
6
|
+
* packages a static bundle, and adding it to `dependencies` would put a
|
|
7
|
+
* platform-specific binary into every install of a package whose runtime needs
|
|
8
|
+
* nothing but `tslib`.
|
|
9
|
+
*
|
|
10
|
+
* NOTE: the specifier goes through a variable so TypeScript does not resolve
|
|
11
|
+
* `esbuild` at compile time. Naming it directly would make the kit fail to
|
|
12
|
+
* build wherever esbuild is absent — which is everywhere it is not being used.
|
|
13
|
+
*/
|
|
14
|
+
export function loadEsbuild() {
|
|
15
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
16
|
+
const specifier = 'esbuild';
|
|
17
|
+
try {
|
|
18
|
+
return (yield import(specifier));
|
|
19
|
+
}
|
|
20
|
+
catch (thrown) {
|
|
21
|
+
const reason = thrown instanceof Error ? thrown.message : String(thrown);
|
|
22
|
+
throw new Error('esbuild is required to build a static bundle but could not be loaded ' +
|
|
23
|
+
`(${reason}). Install it alongside @geniee-inc/sfa-kit ` +
|
|
24
|
+
'(npm install --save-dev esbuild).');
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { buildStaticBundle } from './build-static-bundle.js';
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @description Asks Keystone to sign a bundle, returning the signature.
|
|
3
|
+
*
|
|
4
|
+
* The signing key never leaves Keystone: deploying it to the page builder as
|
|
5
|
+
* well was the original design and was dropped because Studio's secret store is
|
|
6
|
+
* per project while the key must be identical across all of them (SFAJAI-186).
|
|
7
|
+
* The caller computes `contentHash` locally and Keystone signs it.
|
|
8
|
+
*
|
|
9
|
+
* NOTE: every failure throws. An unsigned bundle uploads successfully while
|
|
10
|
+
* Keystone's verifier runs in `disabled` mode, so returning "no signature" here
|
|
11
|
+
* would produce an artifact that looks fine and starts being rejected the day
|
|
12
|
+
* the mode is switched to `enforced` — with nothing on this side to explain it.
|
|
13
|
+
*
|
|
14
|
+
* @param apiBase - Keystone API base. A trailing slash is trimmed.
|
|
15
|
+
* @param accessToken - The same Bearer token the rest of the API is called with.
|
|
16
|
+
*/
|
|
17
|
+
export declare function requestSignature({ apiBase, accessToken, studioVersion, contentHash, }: {
|
|
18
|
+
readonly apiBase: string;
|
|
19
|
+
readonly accessToken: string;
|
|
20
|
+
readonly studioVersion: string;
|
|
21
|
+
readonly contentHash: string;
|
|
22
|
+
}): Promise<string>;
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { __awaiter } from "tslib";
|
|
2
|
+
/**
|
|
3
|
+
* Both the hash sent and the signature returned are hex SHA-256 / HMAC-SHA-256
|
|
4
|
+
* digests, so the same shape applies in each direction.
|
|
5
|
+
*/
|
|
6
|
+
const HEX_DIGEST_PATTERN = /^[0-9a-f]{64}$/;
|
|
7
|
+
/** Matches the timeout the documented Python flow uses for the same call. */
|
|
8
|
+
const SIGN_TIMEOUT_MS = 30000;
|
|
9
|
+
/**
|
|
10
|
+
* @description Asks Keystone to sign a bundle, returning the signature.
|
|
11
|
+
*
|
|
12
|
+
* The signing key never leaves Keystone: deploying it to the page builder as
|
|
13
|
+
* well was the original design and was dropped because Studio's secret store is
|
|
14
|
+
* per project while the key must be identical across all of them (SFAJAI-186).
|
|
15
|
+
* The caller computes `contentHash` locally and Keystone signs it.
|
|
16
|
+
*
|
|
17
|
+
* NOTE: every failure throws. An unsigned bundle uploads successfully while
|
|
18
|
+
* Keystone's verifier runs in `disabled` mode, so returning "no signature" here
|
|
19
|
+
* would produce an artifact that looks fine and starts being rejected the day
|
|
20
|
+
* the mode is switched to `enforced` — with nothing on this side to explain it.
|
|
21
|
+
*
|
|
22
|
+
* @param apiBase - Keystone API base. A trailing slash is trimmed.
|
|
23
|
+
* @param accessToken - The same Bearer token the rest of the API is called with.
|
|
24
|
+
*/
|
|
25
|
+
export function requestSignature(_a) {
|
|
26
|
+
return __awaiter(this, arguments, void 0, function* ({ apiBase, accessToken, studioVersion, contentHash, }) {
|
|
27
|
+
if (!HEX_DIGEST_PATTERN.test(contentHash)) {
|
|
28
|
+
throw new Error(`content_hash must be a 64-character hex digest, got ${JSON.stringify(contentHash)}`);
|
|
29
|
+
}
|
|
30
|
+
// 末尾のスラッシュを落とす。付いたままだと //v1/pages/sign になり、多くの
|
|
31
|
+
// ゲートウェイが 404 を返すため、設定ミスが認証の問題に見える
|
|
32
|
+
const base = apiBase.replace(/\/+$/, '');
|
|
33
|
+
const url = `${base}/v1/pages/sign`;
|
|
34
|
+
// NOTE: a bad host gives "fetch failed" and an empty apiBase "Failed to parse
|
|
35
|
+
// URL from /v1/pages/sign" — neither names the endpoint, the base, or the
|
|
36
|
+
// fact that the bundle is now unsigned.
|
|
37
|
+
const response = yield globalThis
|
|
38
|
+
.fetch(url, {
|
|
39
|
+
// NOTE: without a deadline a server that accepts the connection and never
|
|
40
|
+
// answers hangs the build, leaving an unsigned output directory behind.
|
|
41
|
+
signal: AbortSignal.timeout(SIGN_TIMEOUT_MS),
|
|
42
|
+
method: 'POST',
|
|
43
|
+
headers: {
|
|
44
|
+
'Content-Type': 'application/json',
|
|
45
|
+
Authorization: `Bearer ${accessToken}`,
|
|
46
|
+
},
|
|
47
|
+
body: JSON.stringify({
|
|
48
|
+
studio_version: studioVersion,
|
|
49
|
+
content_hash: contentHash,
|
|
50
|
+
}),
|
|
51
|
+
})
|
|
52
|
+
.catch((thrown) => {
|
|
53
|
+
throw new Error(`POST ${url} could not be reached ` +
|
|
54
|
+
`(${thrown instanceof Error ? thrown.message : String(thrown)}). ` +
|
|
55
|
+
'The bundle was not signed; check the API base and network access.');
|
|
56
|
+
});
|
|
57
|
+
if (!response.ok) {
|
|
58
|
+
const detail = yield response.text().catch(() => '');
|
|
59
|
+
// NOTE: the endpoint exists (SFAJAI-186), so a 404 is a wrong apiBase and a
|
|
60
|
+
// 401 a token from another org — not a reason to ship the bundle unsigned.
|
|
61
|
+
throw new Error(`POST /v1/pages/sign failed: ${response.status} ${response.statusText}` +
|
|
62
|
+
`${detail ? ` — ${detail}` : ''}. The bundle was not signed and must ` +
|
|
63
|
+
'not be uploaded; check the API base and the access token.');
|
|
64
|
+
}
|
|
65
|
+
// NOTE: a proxy answering 200 with an HTML page would throw a bare
|
|
66
|
+
// SyntaxError here, escaping the messages built for every other failure.
|
|
67
|
+
const payload = (yield response.json().catch(() => null));
|
|
68
|
+
if (payload == null || typeof payload.signature !== 'string') {
|
|
69
|
+
throw new Error(`POST ${url} returned no signature. The bundle was not signed.`);
|
|
70
|
+
}
|
|
71
|
+
// NOTE: the hash sent is validated, so the signature coming back is too. An
|
|
72
|
+
// error page echoed into this field would otherwise be written into
|
|
73
|
+
// manifest.json, upload fine while the verifier is `disabled`, and fail only
|
|
74
|
+
// once it is switched to `enforced`.
|
|
75
|
+
if (!HEX_DIGEST_PATTERN.test(payload.signature)) {
|
|
76
|
+
throw new Error(`POST ${url} returned ${JSON.stringify(payload.signature)}, which is ` +
|
|
77
|
+
'not an HMAC-SHA-256 hex digest. The bundle was not signed.');
|
|
78
|
+
}
|
|
79
|
+
return payload.signature;
|
|
80
|
+
});
|
|
81
|
+
}
|
|
@@ -124,6 +124,19 @@ export declare class SfaClient implements SfaClientInterface {
|
|
|
124
124
|
private objectsPath;
|
|
125
125
|
/** @description Returns the field-types catalog path for the current mode. */
|
|
126
126
|
private fieldTypesPath;
|
|
127
|
+
/**
|
|
128
|
+
* @description Sets the auth headers, returning the token it used.
|
|
129
|
+
*
|
|
130
|
+
* NOTE: the caller keeps that token so a 401 can name it. Reading `this.auth`
|
|
131
|
+
* at the 401 would give the same value today — `auth` is readonly and
|
|
132
|
+
* `SfaProvider` builds a new client per token — but returning it from here
|
|
133
|
+
* keeps the answer true by construction.
|
|
134
|
+
*
|
|
135
|
+
* NOTE: this owns the `Authorization` header. It runs after any caller
|
|
136
|
+
* headers are spread in, so a caller-supplied one is overwritten — and if
|
|
137
|
+
* the client has no auth, the returned `undefined` would not describe it.
|
|
138
|
+
* Callers pass `If-Match` and the like, never `Authorization`.
|
|
139
|
+
*/
|
|
127
140
|
private applyAuth;
|
|
128
141
|
/**
|
|
129
142
|
* @description GET request with auth headers. Works for all read-only
|
|
@@ -388,14 +388,31 @@ export class SfaClient {
|
|
|
388
388
|
return this.mode === 'direct' ? '/v1/field-types' : '/field-types';
|
|
389
389
|
}
|
|
390
390
|
// ── Private transport ──
|
|
391
|
+
/**
|
|
392
|
+
* @description Sets the auth headers, returning the token it used.
|
|
393
|
+
*
|
|
394
|
+
* NOTE: the caller keeps that token so a 401 can name it. Reading `this.auth`
|
|
395
|
+
* at the 401 would give the same value today — `auth` is readonly and
|
|
396
|
+
* `SfaProvider` builds a new client per token — but returning it from here
|
|
397
|
+
* keeps the answer true by construction.
|
|
398
|
+
*
|
|
399
|
+
* NOTE: this owns the `Authorization` header. It runs after any caller
|
|
400
|
+
* headers are spread in, so a caller-supplied one is overwritten — and if
|
|
401
|
+
* the client has no auth, the returned `undefined` would not describe it.
|
|
402
|
+
* Callers pass `If-Match` and the like, never `Authorization`.
|
|
403
|
+
*/
|
|
391
404
|
applyAuth(headers) {
|
|
392
405
|
var _a;
|
|
393
|
-
|
|
394
|
-
|
|
406
|
+
const accessToken = (_a = this.auth) === null || _a === void 0 ? void 0 : _a.accessToken;
|
|
407
|
+
if (accessToken) {
|
|
408
|
+
headers['Authorization'] = `Bearer ${accessToken}`;
|
|
395
409
|
}
|
|
396
410
|
if (this.organizationId) {
|
|
397
411
|
headers['organization-id'] = this.organizationId;
|
|
398
412
|
}
|
|
413
|
+
// NOTE: 空文字は「トークンを送っていない」。値として返すと、受け手は
|
|
414
|
+
// 差し替え済みの遅れ着信と区別できず、認証なしの 401 を握り潰す
|
|
415
|
+
return accessToken || undefined;
|
|
399
416
|
}
|
|
400
417
|
/**
|
|
401
418
|
* @description GET request with auth headers. Works for all read-only
|
|
@@ -408,7 +425,7 @@ export class SfaClient {
|
|
|
408
425
|
const headers = {
|
|
409
426
|
'Content-Type': 'application/json',
|
|
410
427
|
};
|
|
411
|
-
this.applyAuth(headers);
|
|
428
|
+
const sentToken = this.applyAuth(headers);
|
|
412
429
|
const response = yield globalThis.fetch(url, {
|
|
413
430
|
method: 'GET',
|
|
414
431
|
headers,
|
|
@@ -416,7 +433,7 @@ export class SfaClient {
|
|
|
416
433
|
});
|
|
417
434
|
if (!response.ok) {
|
|
418
435
|
if (response.status === 401)
|
|
419
|
-
(_a = this.onAuthError) === null || _a === void 0 ? void 0 : _a.call(this);
|
|
436
|
+
(_a = this.onAuthError) === null || _a === void 0 ? void 0 : _a.call(this, sentToken);
|
|
420
437
|
const text = yield response.text().catch(() => '');
|
|
421
438
|
throw new SfaApiError(response.status, response.statusText, text, url);
|
|
422
439
|
}
|
|
@@ -434,7 +451,7 @@ export class SfaClient {
|
|
|
434
451
|
const headers = {
|
|
435
452
|
'Content-Type': 'application/json',
|
|
436
453
|
};
|
|
437
|
-
this.applyAuth(headers);
|
|
454
|
+
const sentToken = this.applyAuth(headers);
|
|
438
455
|
const response = yield globalThis.fetch(url, {
|
|
439
456
|
method: 'POST',
|
|
440
457
|
headers,
|
|
@@ -443,7 +460,7 @@ export class SfaClient {
|
|
|
443
460
|
});
|
|
444
461
|
if (!response.ok) {
|
|
445
462
|
if (response.status === 401)
|
|
446
|
-
(_a = this.onAuthError) === null || _a === void 0 ? void 0 : _a.call(this);
|
|
463
|
+
(_a = this.onAuthError) === null || _a === void 0 ? void 0 : _a.call(this, sentToken);
|
|
447
464
|
const text = yield response.text().catch(() => '');
|
|
448
465
|
throw new SfaApiError(response.status, response.statusText, text, url);
|
|
449
466
|
}
|
|
@@ -460,7 +477,7 @@ export class SfaClient {
|
|
|
460
477
|
var _a;
|
|
461
478
|
const url = `${this.apiBase}${path}`;
|
|
462
479
|
const headers = Object.assign({ 'Content-Type': 'application/json' }, options === null || options === void 0 ? void 0 : options.headers);
|
|
463
|
-
this.applyAuth(headers);
|
|
480
|
+
const sentToken = this.applyAuth(headers);
|
|
464
481
|
const response = yield globalThis.fetch(url, {
|
|
465
482
|
method,
|
|
466
483
|
headers,
|
|
@@ -469,7 +486,7 @@ export class SfaClient {
|
|
|
469
486
|
});
|
|
470
487
|
if (!response.ok) {
|
|
471
488
|
if (response.status === 401)
|
|
472
|
-
(_a = this.onAuthError) === null || _a === void 0 ? void 0 : _a.call(this);
|
|
489
|
+
(_a = this.onAuthError) === null || _a === void 0 ? void 0 : _a.call(this, sentToken);
|
|
473
490
|
const text = yield response.text().catch(() => '');
|
|
474
491
|
if (response.status === 412) {
|
|
475
492
|
throw SfaConflictError.fromBody(response.status, response.statusText, text, url);
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ReactNode } from 'react';
|
|
2
|
-
import type { SfaApiMode, SfaAuth, SfaClientInterface } from './types';
|
|
2
|
+
import type { SfaApiMode, SfaAuth, SfaAuthErrorHandler, SfaClientInterface } from './types';
|
|
3
3
|
export interface SfaContextValue {
|
|
4
4
|
readonly client: SfaClientInterface;
|
|
5
5
|
readonly auth: SfaAuth | undefined;
|
|
@@ -14,7 +14,7 @@ interface SfaProviderProps {
|
|
|
14
14
|
readonly auth?: SfaAuth;
|
|
15
15
|
readonly mode?: SfaApiMode;
|
|
16
16
|
readonly organizationId?: string;
|
|
17
|
-
readonly onAuthError?:
|
|
17
|
+
readonly onAuthError?: SfaAuthErrorHandler;
|
|
18
18
|
/**
|
|
19
19
|
* Disable mutation hooks (create/update/delete). Defaults to false.
|
|
20
20
|
*
|
|
@@ -38,7 +38,7 @@ export function SfaProvider({ apiBase, auth, mode, organizationId, onAuthError,
|
|
|
38
38
|
auth: stableAuth,
|
|
39
39
|
mode: resolvedMode,
|
|
40
40
|
organizationId: resolvedOrgId,
|
|
41
|
-
onAuthError:
|
|
41
|
+
onAuthError: rejectedToken => { var _a; return (_a = onAuthErrorRef.current) === null || _a === void 0 ? void 0 : _a.call(onAuthErrorRef, rejectedToken); },
|
|
42
42
|
}), [apiBase, stableAuth, resolvedMode, resolvedOrgId]);
|
|
43
43
|
const value = useMemo(() => ({
|
|
44
44
|
client,
|
|
@@ -2,12 +2,23 @@ export interface SfaAuth {
|
|
|
2
2
|
readonly accessToken: string;
|
|
3
3
|
}
|
|
4
4
|
export type SfaApiMode = 'direct' | 'proxy';
|
|
5
|
+
/**
|
|
6
|
+
* @description Called when a request came back 401, naming the token it was
|
|
7
|
+
* sent with.
|
|
8
|
+
*
|
|
9
|
+
* `rejectedToken` is the access token that request carried, or `undefined`
|
|
10
|
+
* when it went out unauthenticated. A caller that swaps tokens needs it to
|
|
11
|
+
* tell a late reply about a token it has already replaced from a rejection of
|
|
12
|
+
* the one it holds now: the first is nothing to act on, the second means the
|
|
13
|
+
* session has to be re-established.
|
|
14
|
+
*/
|
|
15
|
+
export type SfaAuthErrorHandler = (rejectedToken: string | undefined) => void;
|
|
5
16
|
export interface SfaClientConfig {
|
|
6
17
|
readonly apiBase: string;
|
|
7
18
|
readonly auth?: SfaAuth;
|
|
8
19
|
readonly mode: SfaApiMode;
|
|
9
20
|
readonly organizationId?: string;
|
|
10
|
-
readonly onAuthError?:
|
|
21
|
+
readonly onAuthError?: SfaAuthErrorHandler;
|
|
11
22
|
}
|
|
12
23
|
/**
|
|
13
24
|
* @description Normalized field kinds aligned with keystone's 10-type model.
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { FileEntry } from './types';
|
|
1
|
+
import type { FileEntry } from './types.js';
|
|
2
2
|
/**
|
|
3
3
|
* Computes the SHA-256 content hash matching Keystone's
|
|
4
4
|
* {@link https://github.com/chikyuinc/chikyu/blob/branch_main/keystone/src/keystone/features/pages/domain/page_bundle_integrity.py compute_content_hash}.
|
|
@@ -4,6 +4,23 @@
|
|
|
4
4
|
*
|
|
5
5
|
* Signs the message `"{studioVersion}:{contentHash}"` with the given key.
|
|
6
6
|
*
|
|
7
|
+
* **The signing key lives only in Keystone (`KEYSTONE_STUDIO__SIGNING_KEY`),
|
|
8
|
+
* so a page build cannot call this.** Deploying the key to Studio as well was
|
|
9
|
+
* the original design and was dropped in SFAJAI-186: Studio's secret store is
|
|
10
|
+
* per project, while the key has to be identical across all of them. Keystone
|
|
11
|
+
* now signs on request, and a page build asks it:
|
|
12
|
+
*
|
|
13
|
+
* ```ts
|
|
14
|
+
* const files = await readBundleFiles('dist');
|
|
15
|
+
* const contentHash = computeContentHash(files);
|
|
16
|
+
* // POST /v1/pages/sign { studio_version, content_hash } -> { signature }
|
|
17
|
+
* ```
|
|
18
|
+
*
|
|
19
|
+
* Reach for this only from a service that legitimately holds the key. Signing
|
|
20
|
+
* with a made-up one is not caught at upload time while the verifier runs in
|
|
21
|
+
* `disabled` mode — the bundle is accepted, and only starts failing once the
|
|
22
|
+
* mode is switched to `enforced`.
|
|
23
|
+
*
|
|
7
24
|
* @param studioVersion - Studio version stored as page metadata.
|
|
8
25
|
* @param contentHash - Hex-encoded SHA-256 from {@link computeContentHash}.
|
|
9
26
|
* @param signingKey - Signing key as a UTF-8 string or raw bytes.
|
|
@@ -5,6 +5,23 @@ import { createHmac } from 'node:crypto';
|
|
|
5
5
|
*
|
|
6
6
|
* Signs the message `"{studioVersion}:{contentHash}"` with the given key.
|
|
7
7
|
*
|
|
8
|
+
* **The signing key lives only in Keystone (`KEYSTONE_STUDIO__SIGNING_KEY`),
|
|
9
|
+
* so a page build cannot call this.** Deploying the key to Studio as well was
|
|
10
|
+
* the original design and was dropped in SFAJAI-186: Studio's secret store is
|
|
11
|
+
* per project, while the key has to be identical across all of them. Keystone
|
|
12
|
+
* now signs on request, and a page build asks it:
|
|
13
|
+
*
|
|
14
|
+
* ```ts
|
|
15
|
+
* const files = await readBundleFiles('dist');
|
|
16
|
+
* const contentHash = computeContentHash(files);
|
|
17
|
+
* // POST /v1/pages/sign { studio_version, content_hash } -> { signature }
|
|
18
|
+
* ```
|
|
19
|
+
*
|
|
20
|
+
* Reach for this only from a service that legitimately holds the key. Signing
|
|
21
|
+
* with a made-up one is not caught at upload time while the verifier runs in
|
|
22
|
+
* `disabled` mode — the bundle is accepted, and only starts failing once the
|
|
23
|
+
* mode is switched to `enforced`.
|
|
24
|
+
*
|
|
8
25
|
* @param studioVersion - Studio version stored as page metadata.
|
|
9
26
|
* @param contentHash - Hex-encoded SHA-256 from {@link computeContentHash}.
|
|
10
27
|
* @param signingKey - Signing key as a UTF-8 string or raw bytes.
|
|
@@ -1,9 +1,19 @@
|
|
|
1
|
-
import type { BundleManifest } from './types';
|
|
1
|
+
import type { BundleManifest } from './types.js';
|
|
2
2
|
/**
|
|
3
3
|
* Reads a dist directory and creates a signed {@link BundleManifest}.
|
|
4
4
|
*
|
|
5
|
-
* Walks the directory tree
|
|
6
|
-
*
|
|
5
|
+
* Walks the directory tree via {@link readBundleFiles} and delegates to
|
|
6
|
+
* {@link createManifest}.
|
|
7
|
+
*
|
|
8
|
+
* **Requires the shared signing key, which page builds do not have.** Keystone
|
|
9
|
+
* holds it and is the only party that signs: an earlier design deployed it to
|
|
10
|
+
* Studio as well, and was abandoned because Studio's secret store is per
|
|
11
|
+
* project while the key must be identical everywhere (SFAJAI-186). Use this
|
|
12
|
+
* only from a service that legitimately holds the key. A page build calls
|
|
13
|
+
* {@link readBundleFiles} + {@link computeContentHash} and posts the hash to
|
|
14
|
+
* `POST /v1/pages/sign` instead — signing with an invented key produces a
|
|
15
|
+
* bundle that uploads today, while the verifier runs in `disabled` mode, and
|
|
16
|
+
* starts failing the moment it is switched to `enforced`.
|
|
7
17
|
*
|
|
8
18
|
* This function is **Node.js-only** and must not be used in browser
|
|
9
19
|
* environments. It does NOT write the manifest file to disk; the caller
|
|
@@ -1,12 +1,21 @@
|
|
|
1
1
|
import { __awaiter } from "tslib";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import { createManifest } from './create-manifest';
|
|
2
|
+
import { createManifest } from './create-manifest.js';
|
|
3
|
+
import { readBundleFiles } from './read-bundle-files.js';
|
|
5
4
|
/**
|
|
6
5
|
* Reads a dist directory and creates a signed {@link BundleManifest}.
|
|
7
6
|
*
|
|
8
|
-
* Walks the directory tree
|
|
9
|
-
*
|
|
7
|
+
* Walks the directory tree via {@link readBundleFiles} and delegates to
|
|
8
|
+
* {@link createManifest}.
|
|
9
|
+
*
|
|
10
|
+
* **Requires the shared signing key, which page builds do not have.** Keystone
|
|
11
|
+
* holds it and is the only party that signs: an earlier design deployed it to
|
|
12
|
+
* Studio as well, and was abandoned because Studio's secret store is per
|
|
13
|
+
* project while the key must be identical everywhere (SFAJAI-186). Use this
|
|
14
|
+
* only from a service that legitimately holds the key. A page build calls
|
|
15
|
+
* {@link readBundleFiles} + {@link computeContentHash} and posts the hash to
|
|
16
|
+
* `POST /v1/pages/sign` instead — signing with an invented key produces a
|
|
17
|
+
* bundle that uploads today, while the verifier runs in `disabled` mode, and
|
|
18
|
+
* starts failing the moment it is switched to `enforced`.
|
|
10
19
|
*
|
|
11
20
|
* This function is **Node.js-only** and must not be used in browser
|
|
12
21
|
* environments. It does NOT write the manifest file to disk; the caller
|
|
@@ -18,29 +27,7 @@ import { createManifest } from './create-manifest';
|
|
|
18
27
|
*/
|
|
19
28
|
export function createManifestFromDirectory(distDir, studioVersion, signingKey) {
|
|
20
29
|
return __awaiter(this, void 0, void 0, function* () {
|
|
21
|
-
const files = yield
|
|
30
|
+
const files = yield readBundleFiles(distDir);
|
|
22
31
|
return createManifest(studioVersion, files, signingKey);
|
|
23
32
|
});
|
|
24
33
|
}
|
|
25
|
-
/**
|
|
26
|
-
* Recursively collects all files under a directory as {@link FileEntry} objects.
|
|
27
|
-
* Paths are normalized to POSIX forward slashes.
|
|
28
|
-
*/
|
|
29
|
-
function collectFiles(dir) {
|
|
30
|
-
return __awaiter(this, void 0, void 0, function* () {
|
|
31
|
-
var _a;
|
|
32
|
-
const entries = yield readdir(dir, { withFileTypes: true, recursive: true });
|
|
33
|
-
const files = [];
|
|
34
|
-
for (const entry of entries) {
|
|
35
|
-
if (!entry.isFile())
|
|
36
|
-
continue;
|
|
37
|
-
const parentPath = (_a = entry.parentPath) !== null && _a !== void 0 ? _a : entry.path;
|
|
38
|
-
const absolutePath = join(parentPath, entry.name);
|
|
39
|
-
const relativePath = relative(dir, absolutePath);
|
|
40
|
-
const posixPath = relativePath.split(/[\\/]/).join(posix.sep);
|
|
41
|
-
const content = yield readFile(absolutePath);
|
|
42
|
-
files.push({ path: posixPath, content });
|
|
43
|
-
}
|
|
44
|
-
return files;
|
|
45
|
-
});
|
|
46
|
-
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { BundleManifest, FileEntry } from './types';
|
|
1
|
+
import type { BundleManifest, FileEntry } from './types.js';
|
|
2
2
|
/**
|
|
3
3
|
* Creates a signed {@link BundleManifest} from a list of bundle files.
|
|
4
4
|
*
|
|
@@ -6,6 +6,12 @@ import type { BundleManifest, FileEntry } from './types';
|
|
|
6
6
|
* Callers can spread decorative fields (e.g. `sfaKitVersion` for the npm
|
|
7
7
|
* package version, `name`, `displayName`) on top before writing to disk.
|
|
8
8
|
*
|
|
9
|
+
* **Requires the shared signing key, which page builds do not have** — see
|
|
10
|
+
* {@link computeSignature}. A page build computes `content_hash` with
|
|
11
|
+
* {@link computeContentHash} and gets the signature from Keystone's
|
|
12
|
+
* `POST /v1/pages/sign`, then writes the three fields as a
|
|
13
|
+
* {@link BundleManifest} literal.
|
|
14
|
+
*
|
|
9
15
|
* @param studioVersion - Studio version stored as page metadata.
|
|
10
16
|
* @param files - All files in the bundle (manifest.json is automatically excluded).
|
|
11
17
|
* @param signingKey - Signing key as a UTF-8 string or raw bytes.
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { computeContentHash } from './compute-content-hash';
|
|
2
|
-
import { computeSignature } from './compute-signature';
|
|
1
|
+
import { computeContentHash } from './compute-content-hash.js';
|
|
2
|
+
import { computeSignature } from './compute-signature.js';
|
|
3
3
|
/**
|
|
4
4
|
* Creates a signed {@link BundleManifest} from a list of bundle files.
|
|
5
5
|
*
|
|
@@ -7,6 +7,12 @@ import { computeSignature } from './compute-signature';
|
|
|
7
7
|
* Callers can spread decorative fields (e.g. `sfaKitVersion` for the npm
|
|
8
8
|
* package version, `name`, `displayName`) on top before writing to disk.
|
|
9
9
|
*
|
|
10
|
+
* **Requires the shared signing key, which page builds do not have** — see
|
|
11
|
+
* {@link computeSignature}. A page build computes `content_hash` with
|
|
12
|
+
* {@link computeContentHash} and gets the signature from Keystone's
|
|
13
|
+
* `POST /v1/pages/sign`, then writes the three fields as a
|
|
14
|
+
* {@link BundleManifest} literal.
|
|
15
|
+
*
|
|
10
16
|
* @param studioVersion - Studio version stored as page metadata.
|
|
11
17
|
* @param files - All files in the bundle (manifest.json is automatically excluded).
|
|
12
18
|
* @param signingKey - Signing key as a UTF-8 string or raw bytes.
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
export type { BundleManifest, FileEntry } from './types';
|
|
2
|
-
export { computeContentHash } from './compute-content-hash';
|
|
3
|
-
export {
|
|
4
|
-
export {
|
|
5
|
-
export {
|
|
1
|
+
export type { BundleManifest, FileEntry } from './types.js';
|
|
2
|
+
export { computeContentHash } from './compute-content-hash.js';
|
|
3
|
+
export { readBundleFiles } from './read-bundle-files.js';
|
|
4
|
+
export { computeSignature } from './compute-signature.js';
|
|
5
|
+
export { createManifest } from './create-manifest.js';
|
|
6
|
+
export { createManifestFromDirectory } from './create-manifest-from-directory.js';
|
package/src/lib/signing/index.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
export { computeContentHash } from './compute-content-hash';
|
|
2
|
-
export {
|
|
3
|
-
export {
|
|
4
|
-
export {
|
|
1
|
+
export { computeContentHash } from './compute-content-hash.js';
|
|
2
|
+
export { readBundleFiles } from './read-bundle-files.js';
|
|
3
|
+
export { computeSignature } from './compute-signature.js';
|
|
4
|
+
export { createManifest } from './create-manifest.js';
|
|
5
|
+
export { createManifestFromDirectory } from './create-manifest-from-directory.js';
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { FileEntry } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* @description Reads every file under a built bundle directory as a
|
|
4
|
+
* {@link FileEntry}, ready for {@link computeContentHash}.
|
|
5
|
+
*
|
|
6
|
+
* This is the key-free half of building a bundle manifest: it needs no signing
|
|
7
|
+
* key, so a page build can compute its own `content_hash` and ask Keystone for
|
|
8
|
+
* the signature (`POST /v1/pages/sign`) rather than holding the shared secret.
|
|
9
|
+
*
|
|
10
|
+
* Paths are relative to `bundleDir` and use forward slashes on every platform,
|
|
11
|
+
* because the content hash is computed over the path bytes — a Windows
|
|
12
|
+
* backslash would hash to a different value than the same bundle built on
|
|
13
|
+
* Linux, and Keystone would reject it.
|
|
14
|
+
*
|
|
15
|
+
* **Node.js only.** Uses `node:fs`, so it must not reach a browser bundle.
|
|
16
|
+
*
|
|
17
|
+
* @param bundleDir - Absolute or relative path to the built bundle (e.g. `dist`).
|
|
18
|
+
*/
|
|
19
|
+
export declare function readBundleFiles(bundleDir: string): Promise<FileEntry[]>;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { __awaiter } from "tslib";
|
|
2
|
+
import { readdir, readFile } from 'node:fs/promises';
|
|
3
|
+
import { join, relative, posix } from 'node:path';
|
|
4
|
+
/**
|
|
5
|
+
* @description Reads every file under a built bundle directory as a
|
|
6
|
+
* {@link FileEntry}, ready for {@link computeContentHash}.
|
|
7
|
+
*
|
|
8
|
+
* This is the key-free half of building a bundle manifest: it needs no signing
|
|
9
|
+
* key, so a page build can compute its own `content_hash` and ask Keystone for
|
|
10
|
+
* the signature (`POST /v1/pages/sign`) rather than holding the shared secret.
|
|
11
|
+
*
|
|
12
|
+
* Paths are relative to `bundleDir` and use forward slashes on every platform,
|
|
13
|
+
* because the content hash is computed over the path bytes — a Windows
|
|
14
|
+
* backslash would hash to a different value than the same bundle built on
|
|
15
|
+
* Linux, and Keystone would reject it.
|
|
16
|
+
*
|
|
17
|
+
* **Node.js only.** Uses `node:fs`, so it must not reach a browser bundle.
|
|
18
|
+
*
|
|
19
|
+
* @param bundleDir - Absolute or relative path to the built bundle (e.g. `dist`).
|
|
20
|
+
*/
|
|
21
|
+
export function readBundleFiles(bundleDir) {
|
|
22
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
23
|
+
var _a;
|
|
24
|
+
const entries = yield readdir(bundleDir, {
|
|
25
|
+
withFileTypes: true,
|
|
26
|
+
recursive: true,
|
|
27
|
+
});
|
|
28
|
+
const files = [];
|
|
29
|
+
for (const entry of entries) {
|
|
30
|
+
if (!entry.isFile())
|
|
31
|
+
continue;
|
|
32
|
+
const parentPath = (_a = entry.parentPath) !== null && _a !== void 0 ? _a : entry.path;
|
|
33
|
+
const absolutePath = join(parentPath, entry.name);
|
|
34
|
+
const relativePath = relative(bundleDir, absolutePath);
|
|
35
|
+
const posixPath = relativePath.split(/[\\/]/).join(posix.sep);
|
|
36
|
+
const content = yield readFile(absolutePath);
|
|
37
|
+
files.push({ path: posixPath, content });
|
|
38
|
+
}
|
|
39
|
+
return files;
|
|
40
|
+
});
|
|
41
|
+
}
|
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.18.0";
|
package/src/lib/version.js
CHANGED
package/src/signing.d.ts
CHANGED
|
@@ -5,14 +5,19 @@
|
|
|
5
5
|
* and should not be imported in browser environments. It uses `node:crypto`
|
|
6
6
|
* for SHA-256 and HMAC-SHA-256 operations.
|
|
7
7
|
*
|
|
8
|
+
* The signing key lives only in Keystone, so a page build computes the content
|
|
9
|
+
* hash here and asks Keystone for the signature. Only `readBundleFiles` and
|
|
10
|
+
* `computeContentHash` are usable without the key.
|
|
11
|
+
*
|
|
8
12
|
* @example
|
|
9
13
|
* ```ts
|
|
10
|
-
* import {
|
|
14
|
+
* import { readBundleFiles, computeContentHash } from '@geniee-inc/sfa-kit/signing';
|
|
11
15
|
*
|
|
12
|
-
* const
|
|
16
|
+
* const contentHash = computeContentHash(await readBundleFiles('./dist'));
|
|
17
|
+
* // POST /v1/pages/sign { studio_version, content_hash } -> { signature }
|
|
13
18
|
* ```
|
|
14
19
|
*
|
|
15
20
|
* @packageDocumentation
|
|
16
21
|
*/
|
|
17
|
-
export type { BundleManifest, FileEntry } from './lib/signing';
|
|
18
|
-
export { computeContentHash, computeSignature, createManifest, createManifestFromDirectory, } from './lib/signing';
|
|
22
|
+
export type { BundleManifest, FileEntry } from './lib/signing/index.js';
|
|
23
|
+
export { computeContentHash, readBundleFiles, computeSignature, createManifest, createManifestFromDirectory, } from './lib/signing/index.js';
|
package/src/signing.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export { computeContentHash, computeSignature, createManifest, createManifestFromDirectory, } from './lib/signing';
|
|
1
|
+
export { computeContentHash, readBundleFiles, computeSignature, createManifest, createManifestFromDirectory, } from './lib/signing/index.js';
|