Skip to content

Commit 26a7bdd

Browse files
committed
feat(cli): use js api client 7.2.0
1 parent f3c82ad commit 26a7bdd

6 files changed

Lines changed: 144 additions & 45 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,12 @@
22

33
All notable changes to this project will be documented in this file.
44

5+
## [5.27.0]
6+
7+
- bumped `@crystallize/js-api-client` to ^7.2.0 (renamed `signature` → `signatureStatus`, `backendToken` → `backendTokenStatus` on the decrypter result; `envelope.config` → `envelope.configuration`; new `tenantId`, `event`, `signatureSecret`, `staticAuthToken` fields on `CrystallizePluginPayload`) and bumped `jose` to ^6 (drops the `KeyLike` re-export in favor of native `CryptoKey`).
8+
- `plugin decrypt-payload` lean output now dumps every protocol section the payload carries: `[Envelope]` (tenantId / tenantIdentifier / installationId / pluginIdentifier / revisionId / event), `[Configuration]`, `[Context]`, `[Secrets]`, `[Plugin Tokens]` (signatureSecret / staticAuthToken), `[JWT]` (envelope iss / aud / sub / iat / nbf / exp / jti) and `[Backend Token]` — sections only render when present, so webhook vs iframe variants stay scannable.
9+
- `plugin decrypt-payload` lean output formats `iat` / `nbf` / `exp` claims as `<unix-seconds> (<ISO-8601>)` for at-a-glance freshness checks.
10+
511
## [5.26.0]
612

713
- added `plugin encrypt-secret` command to encrypt a single secret value with a vendor public JWK (RSA-OAEP-256 / A256GCM), producing a compact JWE on stdout — the same shape Crystallize's UI generates for an `envelope.encryptedSecrets[fieldName]` entry; useful for local dev / testing where the UI is not in the loop. Plaintext can be passed via `--value`, piped on stdin, or pasted interactively. Output round-trips through `plugin decrypt-payload` with the matching private JWK.

‎bun.lockb‎

333 Bytes
Binary file not shown.

‎package.json‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@crystallize/cli",
3-
"version": "5.26.0",
3+
"version": "5.27.0",
44
"description": "Crystallize CLI",
55
"license": "MIT",
66
"contributors": [
@@ -13,16 +13,16 @@
1313
"test": "bun test"
1414
},
1515
"dependencies": {
16-
"@crystallize/js-api-client": "^7.0.0",
16+
"@crystallize/js-api-client": "^7.2.0",
1717
"@crystallize/schema": "^6.12.0",
1818
"awilix": "^13.0.3",
1919
"cli-spinners": "^3.4.0",
2020
"commander": "^14.0.3",
2121
"ink": "^6.8.0",
2222
"ink-link": "^5.0.0",
2323
"ink-text-input": "^6.0.0",
24-
"jose": "^5.10.0",
25-
"jotai": "^2.19.0",
24+
"jose": "^6.2.3",
25+
"jotai": "^2.20.0",
2626
"json-to-graphql-query": "^2.3.0",
2727
"marked": "^17.0.5",
2828
"marked-terminal": "^7.3.0",
@@ -32,7 +32,7 @@
3232
"react": "^19.2.4",
3333
"signale": "^1.4.0",
3434
"tar": "^7.5.13",
35-
"zod": "^4.3.6"
35+
"zod": "^4.4.3"
3636
},
3737
"devDependencies": {
3838
"@commitlint/cli": "^20.5.0",

‎src/command/plugin/decrypt-payload.test.ts‎

Lines changed: 61 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { mkdtemp, readFile, writeFile, rm } from 'node:fs/promises';
33
import { tmpdir } from 'node:os';
44
import path from 'node:path';
55
import { spawnSync } from 'node:child_process';
6-
import { CompactEncrypt, SignJWT, exportJWK, generateKeyPair, importJWK, type JWK, type KeyLike } from 'jose';
6+
import { CompactEncrypt, SignJWT, exportJWK, generateKeyPair, importJWK, type JWK } from 'jose';
77

88
const CLI = path.resolve(import.meta.dir, '../../index.ts');
99

@@ -54,7 +54,7 @@ const encryptBytesForVendor = async (publicJwk: JWK, plaintext: Uint8Array, cty?
5454
type Issuer = {
5555
jwksFilePath: string;
5656
issuer: string;
57-
signer: KeyLike;
57+
signer: CryptoKey;
5858
kid: string;
5959
};
6060

@@ -66,7 +66,7 @@ const makeJwksIssuer = async (dir: string): Promise<Issuer> => {
6666
return {
6767
jwksFilePath,
6868
issuer: 'https://api.crystallize.example',
69-
signer: privateKey as KeyLike,
69+
signer: privateKey,
7070
kid: 'core-sign-1',
7171
};
7272
};
@@ -112,11 +112,12 @@ const buildProtocolPayload = async (args: {
112112
}
113113

114114
const envelopeClaims: Record<string, unknown> = {
115-
installationId: 'inst-1',
115+
tenantId: 'tenant-1',
116116
tenantIdentifier: 'acme',
117+
installationId: 'inst-1',
117118
pluginIdentifier: audience,
118119
revisionId: 'rev-1',
119-
config: { brand: 'Acme' },
120+
configuration: { brand: 'Acme' },
120121
encryptedSecrets,
121122
...envelopeExtras,
122123
};
@@ -238,30 +239,37 @@ describe('plugin decrypt-payload', () => {
238239
expect(result.status).toBe(0);
239240
const out = JSON.parse(result.stdout);
240241
expect(out.protectedHeader.cty).toBe('JWT');
241-
expect(out.signature).toEqual({
242+
expect(out.signatureStatus).toEqual({
242243
verified: true,
243244
issuer: issuer.issuer,
244245
audience,
245246
algorithm: 'RS256',
246247
});
247248
expect(out.envelope.iss).toBe(issuer.issuer);
248249
expect(out.envelope.aud).toBe(audience);
249-
expect(out.envelope.installationId).toBe('inst-1');
250+
expect(out.envelope.tenantId).toBe('tenant-1');
250251
expect(out.envelope.tenantIdentifier).toBe('acme');
252+
expect(out.envelope.installationId).toBe('inst-1');
253+
expect(out.envelope.configuration).toEqual({ brand: 'Acme' });
251254
expect(out.envelope.entityContext).toEqual({ orderId: 'ord-42' });
252255
expect(out.secrets).toEqual({ StripeApiKey: 'sk_live_abc', WebhookSecret: 'whsec_def' });
253256
expect(out.plaintext).toBeNull();
254257
});
255258

256-
test('lean default output has [Secrets] / [Configuration] / [Context] / [Token] sections — no JSON, no headers', async () => {
259+
test('lean default output dumps every protocol section — no JSON, no headers', async () => {
257260
const { publicJwk } = await loadVendorKeys(workDir);
258261
const audience = 'com.vendor.test';
259262
const jwe = await buildProtocolPayload({
260263
vendorPublic: publicJwk,
261264
issuer,
262265
audience,
263266
secretsPlain: { StripeApiKey: 'sk_live_abc' },
264-
envelopeExtras: { entityContext: { orderId: 'ord-42' } },
267+
envelopeExtras: {
268+
entityContext: { orderId: 'ord-42' },
269+
event: 'install',
270+
signatureSecret: 'whsec_lean',
271+
staticAuthToken: 'static_lean_token',
272+
},
265273
backendTokenExtras: { act: { pluginIdentifier: audience } },
266274
});
267275
const result = runDecryptLean(
@@ -281,20 +289,60 @@ describe('plugin decrypt-payload', () => {
281289
workDir,
282290
);
283291
expect(result.status).toBe(0);
284-
expect(result.stdout).toContain('[Secrets]');
285-
expect(result.stdout).toContain('StripeApiKey');
286-
expect(result.stdout).toContain('sk_live_abc');
292+
// Identity
293+
expect(result.stdout).toContain('[Envelope]');
294+
expect(result.stdout).toContain('tenantId');
295+
expect(result.stdout).toContain('tenant-1');
296+
expect(result.stdout).toContain('tenantIdentifier');
297+
expect(result.stdout).toContain('acme');
298+
expect(result.stdout).toContain('installationId');
299+
expect(result.stdout).toContain('inst-1');
300+
expect(result.stdout).toContain('pluginIdentifier');
301+
expect(result.stdout).toContain('revisionId');
302+
expect(result.stdout).toContain('event');
303+
expect(result.stdout).toContain('install');
304+
// Configuration / Context
287305
expect(result.stdout).toContain('[Configuration]');
288306
expect(result.stdout).toContain('brand');
289307
expect(result.stdout).toContain('[Context]');
290308
expect(result.stdout).toContain('orderId');
291-
expect(result.stdout).toContain('[Token]');
309+
// Secrets
310+
expect(result.stdout).toContain('[Secrets]');
311+
expect(result.stdout).toContain('StripeApiKey');
312+
expect(result.stdout).toContain('sk_live_abc');
313+
// Plugin tokens (raw envelope fields)
314+
expect(result.stdout).toContain('[Plugin Tokens]');
315+
expect(result.stdout).toContain('signatureSecret');
316+
expect(result.stdout).toContain('whsec_lean');
317+
expect(result.stdout).toContain('staticAuthToken');
318+
// Envelope JWT claims
319+
expect(result.stdout).toContain('[JWT]');
320+
expect(result.stdout).toContain('iss');
321+
expect(result.stdout).toContain(issuer.issuer);
322+
expect(result.stdout).toContain('exp');
323+
// Backend token
324+
expect(result.stdout).toContain('[Backend Token]');
292325
// No JSON doc, no protected headers in lean mode.
293326
expect(result.stdout).not.toContain('"protectedHeader"');
294327
expect(result.stdout).not.toContain('"innerProtectedHeader"');
295328
expect(result.stderr).not.toContain('outer JWE header');
296329
});
297330

331+
test('lean output omits sections that the payload does not carry', async () => {
332+
const { publicJwk } = await loadVendorKeys(workDir);
333+
const jwe = await encryptBytesForVendor(publicJwk, new TextEncoder().encode('not a jwt'));
334+
const result = runDecryptLean(
335+
['--private-key', path.join(workDir, 'private.jwk.json'), '--payload', jwe],
336+
workDir,
337+
);
338+
expect(result.status).toBe(0);
339+
expect(result.stdout).toContain('[Plaintext]');
340+
expect(result.stdout).not.toContain('[Envelope]');
341+
expect(result.stdout).not.toContain('[JWT]');
342+
expect(result.stdout).not.toContain('[Backend Token]');
343+
expect(result.stdout).not.toContain('[Plugin Tokens]');
344+
});
345+
298346
test('--json flag still emits the structured document', async () => {
299347
const { publicJwk } = await loadVendorKeys(workDir);
300348
const jwe = await encryptBytesForVendor(publicJwk, new TextEncoder().encode('hello'));

‎src/command/plugin/decrypt-payload.ts‎

Lines changed: 69 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -67,58 +67,99 @@ const readFromStdin = async (isTty: boolean, logger: Logger): Promise<string> =>
6767

6868
const looksLikeCompactJwe = (value: string): boolean => value.split('.').length === 5;
6969

70+
const TIME_CLAIMS = new Set(['iat', 'nbf', 'exp']);
71+
72+
const formatTimestamp = (value: unknown): string => {
73+
if (typeof value !== 'number' || !Number.isFinite(value)) return formatValue(value);
74+
// accept seconds (JWT default) or milliseconds (best-effort heuristic).
75+
const ms = value > 1e12 ? value : value * 1000;
76+
const iso = new Date(ms).toISOString();
77+
return `${value} ${pc.dim(`(${iso})`)}`;
78+
};
79+
7080
const formatValue = (value: unknown): string => {
7181
if (value === null || value === undefined) return pc.dim('—');
7282
if (typeof value === 'string') return value;
7383
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
7484
return JSON.stringify(value);
7585
};
7686

87+
const formatClaim = (key: string, value: unknown): string =>
88+
TIME_CLAIMS.has(key) ? formatTimestamp(value) : formatValue(value);
89+
7790
const renderSection = (write: (s: string) => void, title: string, status: string | null, rows: [string, unknown][]) => {
7891
if (rows.length === 0) return;
7992
write('');
8093
write(pc.bold(pc.cyan(`[${title}]`)) + (status ? ' ' + status : ''));
8194
const width = Math.max(...rows.map(([k]) => k.length));
8295
for (const [k, v] of rows) {
83-
write(` ${pc.green(k.padEnd(width))} ${formatValue(v)}`);
96+
write(` ${pc.green(k.padEnd(width))} ${formatClaim(k, v)}`);
8497
}
8598
};
8699

100+
const pickRows = (source: Record<string, unknown> | null | undefined, keys: readonly string[]): [string, unknown][] =>
101+
source ? keys.filter((k) => source[k] !== undefined).map((k) => [k, source[k]]) : [];
102+
103+
const ENVELOPE_IDENTITY_KEYS = [
104+
'tenantId',
105+
'tenantIdentifier',
106+
'installationId',
107+
'pluginIdentifier',
108+
'revisionId',
109+
'event',
110+
] as const;
111+
112+
const ENVELOPE_TOKEN_KEYS = ['signatureSecret', 'staticAuthToken'] as const;
113+
114+
const ENVELOPE_JWT_KEYS = ['iss', 'aud', 'sub', 'iat', 'nbf', 'exp', 'jti'] as const;
115+
116+
const BACKEND_TOKEN_KEYS = ['sub', 'aud', 'iss', 'iat', 'nbf', 'exp', 'jti', 'act'] as const;
117+
87118
const renderLean = (d: DecryptedPluginPayload): void => {
88119
const write = (s: string) => process.stdout.write(s + '\n');
120+
const env = d.envelope as Record<string, unknown> | null;
89121

90-
const secrets = Object.entries(d.secrets);
91-
renderSection(write, 'Secrets', null, secrets);
122+
renderSection(write, 'Envelope', null, pickRows(env, ENVELOPE_IDENTITY_KEYS));
92123

93-
const config = d.envelope?.config;
124+
const configuration = env?.configuration;
94125
renderSection(
95126
write,
96127
'Configuration',
97128
null,
98-
config && typeof config === 'object' && !Array.isArray(config) ? Object.entries(config) : [],
129+
configuration && typeof configuration === 'object' && !Array.isArray(configuration)
130+
? Object.entries(configuration)
131+
: [],
99132
);
100133

101-
const context = d.envelope?.entityContext;
134+
const context = env?.entityContext;
102135
renderSection(
103136
write,
104137
'Context',
105138
null,
106139
context && typeof context === 'object' && !Array.isArray(context) ? Object.entries(context) : [],
107140
);
108141

109-
if (d.backendToken) {
110-
const status = d.backendToken.verified
142+
renderSection(write, 'Secrets', null, Object.entries(d.secrets));
143+
144+
renderSection(write, 'Plugin Tokens', null, pickRows(env, ENVELOPE_TOKEN_KEYS));
145+
146+
renderSection(write, 'JWT', null, pickRows(env, ENVELOPE_JWT_KEYS));
147+
148+
if (d.backendTokenStatus) {
149+
const status = d.backendTokenStatus.verified
111150
? pc.green('(verified)')
112-
: d.backendToken.skipped
151+
: d.backendTokenStatus.skipped
113152
? pc.dim('(not verified)')
114153
: pc.red('(verification FAILED)');
115-
const claims = (d.backendToken.claims ?? {}) as Record<string, unknown>;
116-
const keys = ['sub', 'aud', 'iss', 'iat', 'nbf', 'exp', 'jti', 'act'] as const;
117-
const rows: [string, unknown][] = keys.filter((k) => k in claims).map((k) => [k, claims[k]]);
118-
renderSection(write, 'Token', status, rows);
154+
renderSection(
155+
write,
156+
'Backend Token',
157+
status,
158+
pickRows(d.backendTokenStatus.claims as Record<string, unknown> | undefined, BACKEND_TOKEN_KEYS),
159+
);
119160
}
120161

121-
if (d.envelope === null && d.plaintext !== null) {
162+
if (env === null && d.plaintext !== null) {
122163
write('');
123164
write(pc.bold(pc.cyan('[Plaintext]')));
124165
write(` ${d.plaintext}`);
@@ -174,7 +215,7 @@ export const createPluginDecryptPayloadCommand = ({ logger, flySystem, crystalli
174215
command.addOption(
175216
new Option(
176217
'--json',
177-
'Emit the full structured JSON document (protectedHeader, innerProtectedHeader, envelope, secrets, signature, backendToken) instead of the lean human view.',
218+
'Emit the full structured JSON document (protectedHeader, innerProtectedHeader, envelope, secrets, signatureStatus, backendTokenStatus) instead of the lean human view.',
178219
).default(false),
179220
);
180221
command.addOption(
@@ -192,18 +233,21 @@ ${pc.bold('What this command does (§ reference to the plugin payload protocol):
192233
enforcing ${pc.cyan('issuer')}, ${pc.cyan('audience')}, ${pc.cyan('RS256')}, and a clock tolerance window.
193234
3. Decrypt each per-field ciphertext in ${pc.cyan('envelope.encryptedSecrets')} with the same private key.
194235
4. Optionally verify ${pc.cyan('envelope.backendToken')} (--verify-backend-token).
195-
5. Print a lean summary (${pc.cyan('[Secrets]')} / ${pc.cyan('[Configuration]')} / ${pc.cyan('[Context]')} / ${pc.cyan('[Token]')})
196-
to stdout. Use ${pc.cyan('--json')} for a full structured document (suitable for ${pc.cyan('jq')}),
197-
and ${pc.cyan('-v/--verbose')} to surface protected headers and the "no applicable key" hint.
236+
5. Print a lean summary to stdout. Sections appear only when the payload carries them:
237+
${pc.cyan('[Envelope]')} (tenantId/tenantIdentifier/installationId/pluginIdentifier/revisionId/event),
238+
${pc.cyan('[Configuration]')}, ${pc.cyan('[Context]')} (iframe entityContext), ${pc.cyan('[Secrets]')} (decrypted per-field),
239+
${pc.cyan('[Plugin Tokens]')} (signatureSecret/staticAuthToken), ${pc.cyan('[JWT]')} (envelope iss/aud/sub/iat/nbf/exp/jti),
240+
${pc.cyan('[Backend Token]')} (verification status + claims). Use ${pc.cyan('--json')} for a full structured document
241+
(suitable for ${pc.cyan('jq')}), and ${pc.cyan('-v/--verbose')} to surface protected headers and the "no applicable key" hint.
198242
199243
${pc.bold('Enabling verification:')}
200244
Pass ${pc.cyan('--audience <your-plugin-id>')}. The JWKS URL and issuer default from the env above;
201245
override either with ${pc.cyan('--jwks-url')} / ${pc.cyan('--jwks-file')} / ${pc.cyan('--issuer')} if you're pointing at a mock.
202246
203247
${pc.bold('Signature failures are NOT fatal:')}
204248
When --jwks-url is set and verification fails (e.g. running against a mock issuer on local dev), the
205-
envelope is still decoded and secrets still decrypted, with ${pc.cyan('signature.verified = false')} and the
206-
failure reason. Treat the envelope as untrusted in that case.
249+
envelope is still decoded and secrets still decrypted, with ${pc.cyan('signatureStatus.verified = false')} and
250+
the failure reason. Treat the envelope as untrusted in that case.
207251
208252
${pc.bold('Security:')}
209253
The emitted JSON contains decrypted secrets in cleartext. Never paste or redirect it to shared logs.
@@ -329,22 +373,22 @@ ${pc.bold('Examples:')}
329373

330374
const errLine = (msg: string) => process.stderr.write(msg + '\n');
331375

332-
if (decrypted.signature.verified) {
376+
if (decrypted.signatureStatus.verified) {
333377
errLine(pc.green('✔ ') + 'signature verified');
334-
} else if (decrypted.signature.skipped) {
378+
} else if (decrypted.signatureStatus.skipped) {
335379
if (flags.verbose) {
336-
errLine(pc.yellow('⚠ ') + `signature not verified: ${decrypted.signature.reason}`);
380+
errLine(pc.yellow('⚠ ') + `signature not verified: ${decrypted.signatureStatus.reason}`);
337381
}
338382
} else {
339-
errLine(pc.red('✖ ') + `signature FAILED: ${decrypted.signature.reason}`);
383+
errLine(pc.red('✖ ') + `signature FAILED: ${decrypted.signatureStatus.reason}`);
340384
if (flags.verbose && decrypted.innerProtectedHeader) {
341385
const h = decrypted.innerProtectedHeader;
342386
errLine(
343387
pc.dim(
344388
` inner JWS header: kid=${JSON.stringify(h.kid)}, alg=${JSON.stringify(h.alg)} — compare with JWKS at ${flags.jwksFile ?? flags.jwksUrl}`,
345389
),
346390
);
347-
if (decrypted.signature.reason?.includes('no applicable key')) {
391+
if (decrypted.signatureStatus.reason?.includes('no applicable key')) {
348392
errLine(
349393
pc.dim(
350394
' hint: "no applicable key" means the JWKS was loaded but no key matches the JWT header.',

‎src/domain/use-cases/run-mass-operation.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,9 +41,10 @@ const handler = async (
4141
accessTokenId: envelope.message.credentials.ACCESS_TOKEN_ID,
4242
accessTokenSecret: envelope.message.credentials.ACCESS_TOKEN_SECRET,
4343
});
44-
const operations = OperationsSchema.parse(operationsContent);
44+
const schema = OperationsSchema as unknown as { parse: (input: unknown) => { operations: unknown[] } };
45+
const parsed = schema.parse(operationsContent);
4546
logger.debug(
46-
`Operations file parsed successfully. ${pc.bold(pc.yellow(operations.operations.length))} operation(s) found.`,
47+
`Operations file parsed successfully. ${pc.bold(pc.yellow(parsed.operations.length))} operation(s) found.`,
4748
);
4849

4950
const key = await uploadMassOperationFileContent(JSON.stringify(operationsContent), {

0 commit comments

Comments
 (0)