create-qpq-app 0.1.22 → 0.1.24

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. package/package.json +2 -2
  2. package/template/apps/qpqjs/packages/constants/src/smokeProbe.ts +5 -0
  3. package/template/apps/qpqjs/services/test/models/src/SmokeSigningKeyClaims.ts +6 -0
  4. package/template/apps/qpqjs/services/test/models/src/index.ts +1 -0
  5. package/template/apps/qpqjs/services/test/service/src/smoke/config/defineSmoke.ts +10 -1
  6. package/template/apps/qpqjs/services/test/service/src/smoke/tests/crossService/askRunCrossServiceSigningKeyTest.ts +60 -0
  7. package/template/apps/qpqjs/services/test/service/src/smoke/tests/crossService/index.ts +1 -0
  8. package/template/apps/qpqjs/services/test/service/src/smoke/tests/index.ts +1 -0
  9. package/template/apps/qpqjs/services/test/service/src/smoke/tests/signingKey/askRunSigningKeyTest.ts +87 -0
  10. package/template/apps/qpqjs/services/test/service/src/smoke/tests/signingKey/index.ts +1 -0
  11. package/template/apps/qpqjs/services/test/service/src/smoke/tests/smokeTestRegistry.ts +7 -0
  12. package/template/apps/qpqjs/services/testa/models/src/CrossServiceSigningKeyProbePayload.ts +5 -0
  13. package/template/apps/qpqjs/services/testa/models/src/CrossServiceSigningKeyProbeResult.ts +11 -0
  14. package/template/apps/qpqjs/services/testa/models/src/index.ts +2 -0
  15. package/template/apps/qpqjs/services/testa/service/src/crossServiceProbe/config/defineCrossServiceProbe.ts +16 -0
  16. package/template/apps/qpqjs/services/testa/service/src/crossServiceProbe/entry/serviceFunction/index.ts +1 -0
  17. package/template/apps/qpqjs/services/testa/service/src/crossServiceProbe/entry/serviceFunction/smokeCrossServiceSigningKeyProbe.ts +16 -0
  18. package/template/apps/qpqjs/services/testa/service/src/crossServiceProbe/logic/askProbeForeignSigningKey.ts +36 -0
  19. package/template/apps/qpqjs/services/testa/service/src/crossServiceProbe/logic/index.ts +1 -0
  20. package/template/docusaurus/docs/actions/core/crypto/_category_.json +1 -1
  21. package/template/docusaurus/docs/actions/core/crypto/ask-crypto-get-public-key.md +51 -0
  22. package/template/docusaurus/docs/actions/core/crypto/ask-crypto-sign-jwt.md +53 -0
  23. package/template/docusaurus/docs/actions/core/crypto/ask-crypto-sign.md +54 -0
  24. package/template/docusaurus/docs/actions/core/crypto/ask-crypto-verify-jwt.md +76 -0
  25. package/template/docusaurus/docs/actions/core/crypto/ask-crypto-verify.md +57 -0
  26. package/template/docusaurus/docs/actions/core/file/ask-file-copy.md +72 -0
  27. package/template/docusaurus/docs/actions/features/event-doc/ask-event-doc-copy-asset.md +60 -0
  28. package/template/docusaurus/docs/config/core/crypto-key.md +1 -0
  29. package/template/docusaurus/docs/config/core/signing-key.md +54 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-qpq-app",
3
- "version": "0.1.22",
3
+ "version": "0.1.24",
4
4
  "description": "Scaffold a new quidproquo app: npx create-qpq-app my-app",
5
5
  "main": "./lib/commonjs/index.js",
6
6
  "module": "./lib/esm/index.js",
@@ -55,7 +55,7 @@
55
55
  },
56
56
  "devDependencies": {
57
57
  "@types/node": "^22.13.13",
58
- "quidproquo-tsconfig": "0.1.22"
58
+ "quidproquo-tsconfig": "0.1.24"
59
59
  },
60
60
  "bin": {
61
61
  "create-qpq-app": "./lib/commonjs/bin/createQpqApp.js"
@@ -4,6 +4,9 @@
4
4
  export const SMOKE_PROBE_STORE = 'smokeProbe';
5
5
  // Lowercase with a hyphen: this becomes an S3 bucket name, which must be lowercase.
6
6
  export const SMOKE_PROBE_DRIVE = 'smoke-probe';
7
+ // The signing key the test service owns. testa declares it foreign and
8
+ // verifies tokens the test service signed with it.
9
+ export const SMOKE_PROBE_SIGNING_KEY = 'smokeProbe';
7
10
 
8
11
  // The testa service functions the cross-service smoke tests invoke, one per
9
12
  // foreign grant (the store and the drive are separate IAM statements).
@@ -11,3 +14,5 @@ export const SMOKE_CROSS_SERVICE_KEY_VALUE_STORE_PROBE_FUNCTION_NAME =
11
14
  'crossKvsProbe';
12
15
  export const SMOKE_CROSS_SERVICE_STORAGE_DRIVE_PROBE_FUNCTION_NAME =
13
16
  'crossDriveProbe';
17
+ export const SMOKE_CROSS_SERVICE_SIGNING_KEY_PROBE_FUNCTION_NAME =
18
+ 'crossSigningKeyProbe';
@@ -0,0 +1,6 @@
1
+ // The claims the signing-key smoke tests mint. `runId` ties a token to the run
2
+ // that minted it; `exp` is a JWT NumericDate the verifier checks against QPQ time.
3
+ export type SmokeSigningKeyClaims = {
4
+ runId: string;
5
+ exp: number;
6
+ };
@@ -6,5 +6,6 @@ export * from './SmokeRunStarted';
6
6
  export * from './SmokeRunStatus';
7
7
  export * from './SmokeRunSummary';
8
8
  export * from './SmokeRunWithSummary';
9
+ export * from './SmokeSigningKeyClaims';
9
10
  export * from './SmokeTestResult';
10
11
  export * from './SmokeTestStatus';
@@ -5,6 +5,7 @@ import {
5
5
  defineQueue,
6
6
  defineRoute,
7
7
  defineSecret,
8
+ defineSigningKey,
8
9
  defineStorageDrive,
9
10
  QPQConfig,
10
11
  QpqFunctionRuntime,
@@ -12,7 +13,11 @@ import {
12
13
  import { defineEventDoc } from 'quidproquo-features';
13
14
 
14
15
  import { z } from 'zod/v4';
15
- import { SMOKE_PROBE_DRIVE, SMOKE_PROBE_STORE } from '@qpqjs/constants';
16
+ import {
17
+ SMOKE_PROBE_DRIVE,
18
+ SMOKE_PROBE_SIGNING_KEY,
19
+ SMOKE_PROBE_STORE,
20
+ } from '@qpqjs/constants';
16
21
  import {
17
22
  SmokeProbeRecord,
18
23
  SmokeRunStartedSchema,
@@ -83,6 +88,10 @@ export const defineSmoke = (): QPQConfig => {
83
88
  }),
84
89
  defineSecret(SMOKE_PROBE_SECRET),
85
90
  defineStorageDrive(SMOKE_PROBE_DRIVE),
91
+ // An owned RSA signing key: kms:Sign + kms:GetPublicKey through the
92
+ // alias-conditioned grant. testa declares the same key foreign (see
93
+ // defineCrossServiceProbe) for the cross-service verify test.
94
+ defineSigningKey(SMOKE_PROBE_SIGNING_KEY),
86
95
 
87
96
  // Event bus test path: publish to the bus, the subscribed queue's entry
88
97
  // writes a marker into the probe store, the test polls for it.
@@ -0,0 +1,60 @@
1
+ import {
2
+ askCryptoGetPublicKey,
3
+ askCryptoSignJwt,
4
+ askGetCurrentEpoch,
5
+ AskResponse,
6
+ askServiceFunctionExecute,
7
+ } from 'quidproquo';
8
+
9
+ import {
10
+ QpqjsServiceEnum,
11
+ SMOKE_CROSS_SERVICE_SIGNING_KEY_PROBE_FUNCTION_NAME,
12
+ SMOKE_PROBE_SIGNING_KEY,
13
+ } from '@qpqjs/constants';
14
+ import { SmokeSigningKeyClaims } from '@qpqjs/test-models';
15
+ import {
16
+ CrossServiceSigningKeyProbePayload,
17
+ CrossServiceSigningKeyProbeResult,
18
+ } from '@qpqjs/testa-models';
19
+
20
+ import { askSmokeAssert } from '../askSmokeAssert';
21
+
22
+ // The issuer/verifier split a signing key exists for: THIS service signs a
23
+ // jwt with its owned key, testa verifies it through its foreign declaration
24
+ // (owner: test) of the same key. An error from inside testa is its
25
+ // kms:GetPublicKey grant or the cross-module alias resolution; a valid result
26
+ // with our runId proves both services resolved the same physical key, which
27
+ // the public key comparison then confirms directly. testa also tries to sign
28
+ // with the key: a foreign declaration holds no kms:Sign, so that must fail.
29
+ export function* askRunCrossServiceSigningKeyTest(
30
+ runId: string
31
+ ): AskResponse<void> {
32
+ const now = yield* askGetCurrentEpoch();
33
+ const claims: SmokeSigningKeyClaims = { runId, exp: now + 60 };
34
+ const token = yield* askCryptoSignJwt(SMOKE_PROBE_SIGNING_KEY, claims);
35
+
36
+ const result = yield* askServiceFunctionExecute<
37
+ CrossServiceSigningKeyProbeResult,
38
+ CrossServiceSigningKeyProbePayload
39
+ >(
40
+ QpqjsServiceEnum.TestA,
41
+ SMOKE_CROSS_SERVICE_SIGNING_KEY_PROBE_FUNCTION_NAME,
42
+ { token }
43
+ );
44
+
45
+ yield* askSmokeAssert(
46
+ result.runId === runId,
47
+ `testa did not verify our jwt (${result.runId ?? 'invalid'})`
48
+ );
49
+
50
+ const publicKeyPem = yield* askCryptoGetPublicKey(SMOKE_PROBE_SIGNING_KEY);
51
+ yield* askSmokeAssert(
52
+ result.publicKeyPem === publicKeyPem,
53
+ 'testa resolved a different public key than the owning service'
54
+ );
55
+
56
+ yield* askSmokeAssert(
57
+ !result.couldSign,
58
+ 'testa was able to sign with a key it does not own'
59
+ );
60
+ }
@@ -1,2 +1,3 @@
1
1
  export * from './askRunCrossServiceKeyValueStoreTest';
2
+ export * from './askRunCrossServiceSigningKeyTest';
2
3
  export * from './askRunCrossServiceStorageDriveTest';
@@ -7,6 +7,7 @@ export * from './noop';
7
7
  export * from './openApi';
8
8
  export * from './parameter';
9
9
  export * from './secret';
10
+ export * from './signingKey';
10
11
  export * from './SmokeTestDefinition';
11
12
  export * from './smokeTestRegistry';
12
13
  export * from './storageDrive';
@@ -0,0 +1,87 @@
1
+ import {
2
+ askCryptoGetPublicKey,
3
+ askCryptoSign,
4
+ askCryptoSignJwt,
5
+ askCryptoVerify,
6
+ askCryptoVerifyJwt,
7
+ askGetCurrentEpoch,
8
+ AskResponse,
9
+ } from 'quidproquo';
10
+
11
+ import { SMOKE_PROBE_SIGNING_KEY } from '@qpqjs/constants';
12
+ import { SmokeSigningKeyClaims } from '@qpqjs/test-models';
13
+
14
+ import { askSmokeAssert } from '../askSmokeAssert';
15
+
16
+ const PUBLIC_KEY_PEM_HEADER = '-----BEGIN PUBLIC KEY-----';
17
+
18
+ // Every signing-key action against the owned key: kms:Sign (askCryptoSign /
19
+ // askCryptoSignJwt) and kms:GetPublicKey (askCryptoGetPublicKey, and the
20
+ // in-process askCryptoVerify behind askCryptoVerifyJwt). Then the two ways a
21
+ // token must fail - a flipped signature byte and a past exp - so a verifier
22
+ // that accepts everything cannot pass.
23
+ export function* askRunSigningKeyTest(runId: string): AskResponse<void> {
24
+ const publicKeyPem = yield* askCryptoGetPublicKey(SMOKE_PROBE_SIGNING_KEY);
25
+ yield* askSmokeAssert(
26
+ publicKeyPem.startsWith(PUBLIC_KEY_PEM_HEADER),
27
+ 'GetPublicKey did not return an SPKI PEM'
28
+ );
29
+
30
+ const message = `smoke probe ${runId}`;
31
+ const signature = yield* askCryptoSign(SMOKE_PROBE_SIGNING_KEY, message);
32
+ yield* askSmokeAssert(
33
+ signature.length > 0,
34
+ 'Sign returned an empty signature'
35
+ );
36
+
37
+ const verified = yield* askCryptoVerify(
38
+ SMOKE_PROBE_SIGNING_KEY,
39
+ message,
40
+ signature
41
+ );
42
+ yield* askSmokeAssert(verified, 'raw signature did not verify');
43
+
44
+ const now = yield* askGetCurrentEpoch();
45
+
46
+ const claims: SmokeSigningKeyClaims = { runId, exp: now + 60 };
47
+ const token = yield* askCryptoSignJwt(SMOKE_PROBE_SIGNING_KEY, claims);
48
+
49
+ const result = yield* askCryptoVerifyJwt<SmokeSigningKeyClaims>(
50
+ SMOKE_PROBE_SIGNING_KEY,
51
+ token
52
+ );
53
+ yield* askSmokeAssert(
54
+ result.valid && result.claims.runId === runId,
55
+ 'signed jwt did not verify with its own claims'
56
+ );
57
+
58
+ // Corrupt a character in the MIDDLE of the signature segment. Not the last
59
+ // one: a 2048-bit signature is 342 base64url chars and the final char
60
+ // carries only two real bits, so flipping it can leave the decoded bytes
61
+ // unchanged and the token still valid.
62
+ const [header, payload, signatureSegment] = token.split('.');
63
+ const middle = Math.floor(signatureSegment.length / 2);
64
+ const flipped = signatureSegment[middle] === 'A' ? 'B' : 'A';
65
+ const tamperedToken = `${header}.${payload}.${signatureSegment.slice(0, middle)}${flipped}${signatureSegment.slice(middle + 1)}`;
66
+ const tampered = yield* askCryptoVerifyJwt<SmokeSigningKeyClaims>(
67
+ SMOKE_PROBE_SIGNING_KEY,
68
+ tamperedToken
69
+ );
70
+ yield* askSmokeAssert(
71
+ !tampered.valid && tampered.reason === 'bad-signature',
72
+ 'tampered jwt was not rejected as bad-signature'
73
+ );
74
+
75
+ const expiredToken = yield* askCryptoSignJwt(SMOKE_PROBE_SIGNING_KEY, {
76
+ runId,
77
+ exp: now - 60,
78
+ });
79
+ const expired = yield* askCryptoVerifyJwt<SmokeSigningKeyClaims>(
80
+ SMOKE_PROBE_SIGNING_KEY,
81
+ expiredToken
82
+ );
83
+ yield* askSmokeAssert(
84
+ !expired.valid && expired.reason === 'expired',
85
+ 'expired jwt was not rejected as expired'
86
+ );
87
+ }
@@ -0,0 +1 @@
1
+ export * from './askRunSigningKeyTest';
@@ -1,4 +1,5 @@
1
1
  import { askRunCrossServiceKeyValueStoreTest } from './crossService/askRunCrossServiceKeyValueStoreTest';
2
+ import { askRunCrossServiceSigningKeyTest } from './crossService/askRunCrossServiceSigningKeyTest';
2
3
  import { askRunCrossServiceStorageDriveTest } from './crossService/askRunCrossServiceStorageDriveTest';
3
4
  import { askRunEventBusTest } from './eventBus/askRunEventBusTest';
4
5
  import { askRunEventDocConcurrentAppendTest } from './eventDoc/askRunEventDocConcurrentAppendTest';
@@ -10,6 +11,7 @@ import { askRunOpenApiDocumentTest } from './openApi/askRunOpenApiDocumentTest';
10
11
  import { askRunParameterTest } from './parameter/askRunParameterTest';
11
12
  import { askRunScheduleTest } from './schedule/askRunScheduleTest';
12
13
  import { askRunSecretTest } from './secret/askRunSecretTest';
14
+ import { askRunSigningKeyTest } from './signingKey/askRunSigningKeyTest';
13
15
  import { askRunStorageDriveTest } from './storageDrive/askRunStorageDriveTest';
14
16
  import { SmokeTestDefinition } from './SmokeTestDefinition';
15
17
 
@@ -44,4 +46,9 @@ export const smokeTestRegistry: SmokeTestDefinition[] = [
44
46
  name: 'eventDocInterleavedBatchAppend',
45
47
  askRun: askRunEventDocInterleavedBatchAppendTest,
46
48
  },
49
+ { name: 'signingKey', askRun: askRunSigningKeyTest },
50
+ {
51
+ name: 'crossServiceSigningKey',
52
+ askRun: askRunCrossServiceSigningKeyTest,
53
+ },
47
54
  ];
@@ -0,0 +1,5 @@
1
+ // Input to testa's smokeCrossServiceSigningKeyProbe service function: a jwt
2
+ // the test service signed with its owned signing key.
3
+ export type CrossServiceSigningKeyProbePayload = {
4
+ token: string;
5
+ };
@@ -0,0 +1,11 @@
1
+ import { Nullable } from 'quidproquo';
2
+
3
+ // What testa made of the token through its foreign declaration of the test
4
+ // service's signing key: the runId claim when the jwt verified (null when it
5
+ // did not), the public key it resolved so the caller can compare it with its
6
+ // own, and whether it was ALLOWED to sign with the key - it must not be.
7
+ export type CrossServiceSigningKeyProbeResult = {
8
+ runId: Nullable<string>;
9
+ publicKeyPem: string;
10
+ couldSign: boolean;
11
+ };
@@ -1,3 +1,5 @@
1
1
  export * from './CrossServiceKeyValueStoreProbeResult';
2
2
  export * from './CrossServiceProbePayload';
3
+ export * from './CrossServiceSigningKeyProbePayload';
4
+ export * from './CrossServiceSigningKeyProbeResult';
3
5
  export * from './CrossServiceStorageDriveProbeResult';
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  defineKeyValueStore,
3
3
  defineServiceFunction,
4
+ defineSigningKey,
4
5
  defineStorageDrive,
5
6
  QPQConfig,
6
7
  } from 'quidproquo';
@@ -8,8 +9,10 @@ import {
8
9
  import {
9
10
  QpqjsServiceEnum,
10
11
  SMOKE_CROSS_SERVICE_KEY_VALUE_STORE_PROBE_FUNCTION_NAME,
12
+ SMOKE_CROSS_SERVICE_SIGNING_KEY_PROBE_FUNCTION_NAME,
11
13
  SMOKE_CROSS_SERVICE_STORAGE_DRIVE_PROBE_FUNCTION_NAME,
12
14
  SMOKE_PROBE_DRIVE,
15
+ SMOKE_PROBE_SIGNING_KEY,
13
16
  SMOKE_PROBE_STORE,
14
17
  } from '@qpqjs/constants';
15
18
  import { SmokeProbeRecord } from '@qpqjs/test-models';
@@ -27,6 +30,10 @@ export const defineCrossServiceProbe = (): QPQConfig => {
27
30
  owner,
28
31
  }),
29
32
  defineStorageDrive(SMOKE_PROBE_DRIVE, { owner }),
33
+ // The signing key is the one foreign resource whose grant is alias-
34
+ // conditioned rather than exact-ARN: the owner's alias name is what this
35
+ // declaration must resolve to, or GetPublicKey is denied.
36
+ defineSigningKey(SMOKE_PROBE_SIGNING_KEY, { owner }),
30
37
 
31
38
  // One service function per foreign grant, invoked by the test service's
32
39
  // crossService* smoke tests.
@@ -48,5 +55,14 @@ export const defineCrossServiceProbe = (): QPQConfig => {
48
55
  },
49
56
  { functionName: SMOKE_CROSS_SERVICE_STORAGE_DRIVE_PROBE_FUNCTION_NAME }
50
57
  ),
58
+ defineServiceFunction(
59
+ {
60
+ basePath: __dirname,
61
+ relativePath:
62
+ '../entry/serviceFunction/smokeCrossServiceSigningKeyProbe',
63
+ functionName: 'smokeCrossServiceSigningKeyProbe',
64
+ },
65
+ { functionName: SMOKE_CROSS_SERVICE_SIGNING_KEY_PROBE_FUNCTION_NAME }
66
+ ),
51
67
  ];
52
68
  };
@@ -1,2 +1,3 @@
1
1
  export * from './smokeCrossServiceKeyValueStoreProbe';
2
+ export * from './smokeCrossServiceSigningKeyProbe';
2
3
  export * from './smokeCrossServiceStorageDriveProbe';
@@ -0,0 +1,16 @@
1
+ import { AskResponse, ExecuteServiceFunctionEvent } from 'quidproquo';
2
+
3
+ import {
4
+ CrossServiceSigningKeyProbePayload,
5
+ CrossServiceSigningKeyProbeResult,
6
+ } from '@qpqjs/testa-models';
7
+
8
+ import { askProbeForeignSigningKey } from '../../logic/askProbeForeignSigningKey';
9
+
10
+ // Service function entry, called cross-service by the test service's
11
+ // crossServiceSigningKey smoke test.
12
+ export function* smokeCrossServiceSigningKeyProbe(
13
+ event: ExecuteServiceFunctionEvent<CrossServiceSigningKeyProbePayload>
14
+ ): AskResponse<CrossServiceSigningKeyProbeResult> {
15
+ return yield* askProbeForeignSigningKey(event.payload.token);
16
+ }
@@ -0,0 +1,36 @@
1
+ import {
2
+ askCatch,
3
+ askCryptoGetPublicKey,
4
+ askCryptoSign,
5
+ askCryptoVerifyJwt,
6
+ AskResponse,
7
+ } from 'quidproquo';
8
+
9
+ import { SMOKE_PROBE_SIGNING_KEY } from '@qpqjs/constants';
10
+ import { SmokeSigningKeyClaims } from '@qpqjs/test-models';
11
+ import { CrossServiceSigningKeyProbeResult } from '@qpqjs/testa-models';
12
+
13
+ // Verifies a jwt the test service signed, under THIS service's role and
14
+ // through its foreign (owner: test) declaration of the key: kms:GetPublicKey
15
+ // on the owner's alias, then an in-process RS256 check. Then tries to sign
16
+ // with the same key, which the foreign declaration must NOT be granted.
17
+ export function* askProbeForeignSigningKey(
18
+ token: string
19
+ ): AskResponse<CrossServiceSigningKeyProbeResult> {
20
+ const result = yield* askCryptoVerifyJwt<SmokeSigningKeyClaims>(
21
+ SMOKE_PROBE_SIGNING_KEY,
22
+ token
23
+ );
24
+
25
+ const publicKeyPem = yield* askCryptoGetPublicKey(SMOKE_PROBE_SIGNING_KEY);
26
+
27
+ const signAttempt = yield* askCatch(
28
+ askCryptoSign(SMOKE_PROBE_SIGNING_KEY, 'must-be-denied')
29
+ );
30
+
31
+ return {
32
+ runId: result.valid ? result.claims.runId : null,
33
+ publicKeyPem,
34
+ couldSign: signAttempt.success,
35
+ };
36
+ }
@@ -1,2 +1,3 @@
1
1
  export * from './askProbeForeignKeyValueStore';
2
+ export * from './askProbeForeignSigningKey';
2
3
  export * from './askProbeForeignStorageDrive';
@@ -2,6 +2,6 @@
2
2
  "label": "Crypto",
3
3
  "link": {
4
4
  "type": "generated-index",
5
- "description": "Crypto actions encrypt and decrypt values with a key declared by defineCryptoKey. Envelope encryption with an optional context that binds ciphertext to the place it was created."
5
+ "description": "Crypto actions encrypt and decrypt values with a key declared by defineCryptoKey, and sign and verify messages or JWTs with an asymmetric key declared by defineSigningKey. The key material never leaves the provider."
6
6
  }
7
7
  }
@@ -0,0 +1,51 @@
1
+ ---
2
+ title: askCryptoGetPublicKey
3
+ description: Read the public half of a signing key as an SPKI PEM, for third parties to verify with.
4
+ ---
5
+
6
+ # askCryptoGetPublicKey
7
+
8
+ Returns the public half of a [signing key](../../../config/core/signing-key.md) as an SPKI PEM string. It is not sensitive: it can only verify, never sign. Hand it to anyone who needs to check your signatures without calling back in, for example from a `/.well-known/jwks.json` route or a partner integration.
9
+
10
+ - **Action type:** `CryptoActionType.GetPublicKey`
11
+ - **On AWS:** `kms:GetPublicKey`, cached per runtime for an hour.
12
+ - **On the dev server:** the public half of the locally seeded pair.
13
+
14
+ ```typescript
15
+ import { askCryptoGetPublicKey } from 'quidproquo-core';
16
+
17
+ export function* askGetTokenVerificationKey() {
18
+ return yield* askCryptoGetPublicKey('access-token-key');
19
+ }
20
+ ```
21
+
22
+ ## Signature
23
+
24
+ ```typescript
25
+ function* askCryptoGetPublicKey(
26
+ keyName: string,
27
+ ): AskResponse<string>;
28
+ ```
29
+
30
+ ## Parameters
31
+
32
+ | Parameter | Type | Description |
33
+ | --- | --- | --- |
34
+ | `keyName` | `string` | Name of the signing key, declared with [defineSigningKey](../../../config/core/signing-key.md) (or shared via its `owner` option). |
35
+
36
+ ## Returns
37
+
38
+ `string`: the public key as a PEM block (`-----BEGIN PUBLIC KEY-----` … `-----END PUBLIC KEY-----`), SubjectPublicKeyInfo encoding, as accepted by every JWT library and `openssl`.
39
+
40
+ ## Errors
41
+
42
+ | Error | Meaning |
43
+ | --- | --- |
44
+ | `askCryptoGetPublicKey.errorType.KeyNotConfigured` | No `defineSigningKey` with that name exists in the service config. |
45
+ | `askCryptoGetPublicKey.errorType.KeyUnavailable` | The key exists in config but is disabled, deleted, or access was denied. |
46
+ | `askCryptoGetPublicKey.errorType.Throttling` | The provider rate limit was exceeded; back off and retry. |
47
+
48
+ ## Related
49
+
50
+ - [defineSigningKey](../../../config/core/signing-key.md): declares the key this action uses.
51
+ - [askCryptoVerify](./ask-crypto-verify.md): verifies in-process without exposing the key.
@@ -0,0 +1,53 @@
1
+ ---
2
+ title: askCryptoSignJwt
3
+ description: Mint a compact RS256 JWT over a claims object with a signing key.
4
+ ---
5
+
6
+ # askCryptoSignJwt
7
+
8
+ Builds a compact JWT (`header.payload.signature`) over `claims`, signed RS256 with a [signing key](../../../config/core/signing-key.md). A story-level helper over [askCryptoSign](./ask-crypto-sign.md): the header is fixed at `{ "alg": "RS256", "typ": "JWT" }`, both segments are base64url, and the signature comes from the key's private half without it ever entering the story.
9
+
10
+ Every time-based claim (`exp`, `nbf`, `iat`) must already be on `claims`, sourced from QPQ time, so the story stays deterministic. Nothing is added or rewritten.
11
+
12
+ ```typescript
13
+ import { askCryptoSignJwt, askGetCurrentEpoch } from 'quidproquo-core';
14
+
15
+ export function* askIssueAccessToken(clientId: string, grants: string[]) {
16
+ const now = yield* askGetCurrentEpoch();
17
+
18
+ return yield* askCryptoSignJwt('access-token-key', {
19
+ sub: clientId,
20
+ grants,
21
+ exp: now + 3600,
22
+ });
23
+ }
24
+ ```
25
+
26
+ ## Signature
27
+
28
+ ```typescript
29
+ function* askCryptoSignJwt(
30
+ keyName: string,
31
+ claims: object,
32
+ ): AskResponse<string>;
33
+ ```
34
+
35
+ ## Parameters
36
+
37
+ | Parameter | Type | Description |
38
+ | --- | --- | --- |
39
+ | `keyName` | `string` | Name of the signing key, declared with [defineSigningKey](../../../config/core/signing-key.md). |
40
+ | `claims` | `object` | The JWT payload, serialised with `JSON.stringify`. Put `exp`/`nbf` here as unix seconds if you want [askCryptoVerifyJwt](./ask-crypto-verify-jwt.md) to enforce them. |
41
+
42
+ ## Returns
43
+
44
+ `string`: the compact JWT.
45
+
46
+ ## Errors
47
+
48
+ Those of [askCryptoSign](./ask-crypto-sign.md).
49
+
50
+ ## Related
51
+
52
+ - [askCryptoVerifyJwt](./ask-crypto-verify-jwt.md): the matching verifier.
53
+ - [defineSigningKey](../../../config/core/signing-key.md): declares the key.
@@ -0,0 +1,54 @@
1
+ ---
2
+ title: askCryptoSign
3
+ description: Sign a message with a signing key's private half and get back a base64url RS256 signature.
4
+ ---
5
+
6
+ # askCryptoSign
7
+
8
+ Signs a string with a [signing key](../../../config/core/signing-key.md) and returns the RS256 signature as unpadded base64url. The private key never leaves the provider; the story only ever sees the signature. Most stories want [askCryptoSignJwt](./ask-crypto-sign-jwt.md) instead, which builds the JWT framing around this.
9
+
10
+ - **Action type:** `CryptoActionType.Sign`
11
+ - **On AWS:** one `kms:Sign` call (`RSASSA_PKCS1_V1_5_SHA_256`) against the key provisioned by [defineSigningKey](../../../config/core/signing-key.md).
12
+ - **On the dev server:** the same signature computed with Node's `crypto` against the locally seeded pair at `.qpq-runtime/<app>/signingKeys/<service>.json`.
13
+
14
+ ```typescript
15
+ import { askCryptoSign } from 'quidproquo-core';
16
+
17
+ export function* askSignWebhookBody(body: string) {
18
+ return yield* askCryptoSign('webhook-signing-key', body);
19
+ }
20
+ ```
21
+
22
+ ## Signature
23
+
24
+ ```typescript
25
+ function* askCryptoSign(
26
+ keyName: string,
27
+ message: string,
28
+ ): AskResponse<string>;
29
+ ```
30
+
31
+ ## Parameters
32
+
33
+ | Parameter | Type | Description |
34
+ | --- | --- | --- |
35
+ | `keyName` | `string` | Name of the signing key, declared with [defineSigningKey](../../../config/core/signing-key.md) (or shared via its `owner` option). |
36
+ | `message` | `string` | The exact bytes (UTF-8) to sign. Verification must be given the identical string. |
37
+
38
+ ## Returns
39
+
40
+ `string`: the signature, base64url encoded without padding. RS256 is deterministic, so the same key and message always return the same value.
41
+
42
+ ## Errors
43
+
44
+ | Error | Meaning |
45
+ | --- | --- |
46
+ | `askCryptoSign.errorType.KeyNotConfigured` | No `defineSigningKey` with that name exists in the service config. |
47
+ | `askCryptoSign.errorType.KeyUnavailable` | The key exists in config but is disabled, deleted, or access was denied. Infrastructure problem; surface to ops. |
48
+ | `askCryptoSign.errorType.Throttling` | The provider rate limit was exceeded; back off and retry. |
49
+
50
+ ## Related
51
+
52
+ - [defineSigningKey](../../../config/core/signing-key.md): declares the key this action uses.
53
+ - [askCryptoVerify](./ask-crypto-verify.md): checks a signature.
54
+ - [askCryptoSignJwt](./ask-crypto-sign-jwt.md): mints a JWT with this action.
@@ -0,0 +1,76 @@
1
+ ---
2
+ title: askCryptoVerifyJwt
3
+ description: Verify a compact RS256 JWT against a signing key and get its claims, or a reason it was rejected.
4
+ ---
5
+
6
+ # askCryptoVerifyJwt
7
+
8
+ Verifies a compact JWT against a [signing key](../../../config/core/signing-key.md) and returns its claims. Built for untrusted input: it never throws on a bad token. Every way a token can be wrong comes back as `{ valid: false, reason }`, so a route can map it straight to a 401 without a try/catch.
9
+
10
+ Checks, in order: three base64url segments; header is JSON with `alg: "RS256"` (anything else, including `none`, is rejected before any verification); signature verifies under the key via [askCryptoVerify](./ask-crypto-verify.md); payload is a JSON object; `nbf` is not in the future and `exp` is not in the past, against QPQ time. Audience, issuer and every application claim are yours to check on the returned claims.
11
+
12
+ ```typescript
13
+ import { askCryptoVerifyJwt, askThrowError, ErrorTypeEnum } from 'quidproquo-core';
14
+
15
+ type AccessTokenClaims = { sub: string; grants: string[]; exp: number };
16
+
17
+ export function* askAuthenticateAccessToken(token: string) {
18
+ const result = yield* askCryptoVerifyJwt<AccessTokenClaims>('access-token-key', token);
19
+
20
+ if (!result.valid) {
21
+ return yield* askThrowError(ErrorTypeEnum.Unauthorized, `Invalid access token: ${result.reason}`);
22
+ }
23
+
24
+ return result.claims;
25
+ }
26
+ ```
27
+
28
+ ## Signature
29
+
30
+ ```typescript
31
+ function* askCryptoVerifyJwt<T extends object>(
32
+ keyName: string,
33
+ token: string,
34
+ ): AskResponse<JwtVerifyResult<T>>;
35
+
36
+ type JwtVerifyResult<T> =
37
+ | { valid: true; claims: T }
38
+ | { valid: false; reason: JwtVerifyFailureReason };
39
+
40
+ type JwtVerifyFailureReason =
41
+ | 'malformed'
42
+ | 'unsupported-algorithm'
43
+ | 'bad-signature'
44
+ | 'not-yet-valid'
45
+ | 'expired';
46
+ ```
47
+
48
+ ## Parameters
49
+
50
+ | Parameter | Type | Description |
51
+ | --- | --- | --- |
52
+ | `keyName` | `string` | Name of the signing key whose public half verifies the token. A service that only verifies declares the key with `owner` pointing at the issuing service. |
53
+ | `token` | `string` | The compact JWT as presented, e.g. the bearer value from an `Authorization` header. |
54
+
55
+ ## Returns
56
+
57
+ `JwtVerifyResult<T>`: `{ valid: true, claims }` with the payload typed as `T`, or `{ valid: false, reason }`:
58
+
59
+ | Reason | Meaning |
60
+ | --- | --- |
61
+ | `malformed` | Not three base64url segments, or the header/payload is not a JSON object. |
62
+ | `unsupported-algorithm` | The header `alg` is not `RS256`. |
63
+ | `bad-signature` | The signature does not verify under the key. |
64
+ | `not-yet-valid` | `nbf` is later than now. |
65
+ | `expired` | `exp` is now or earlier. |
66
+
67
+ Time claims are compared as unix seconds; a token with neither `exp` nor `nbf` is accepted without reading the clock.
68
+
69
+ ## Errors
70
+
71
+ Those of [askCryptoVerify](./ask-crypto-verify.md): a key that is not configured or not available is an infrastructure error, not a `valid: false` result. `MalformedSignature` cannot occur here, since the segments are validated as base64url first.
72
+
73
+ ## Related
74
+
75
+ - [askCryptoSignJwt](./ask-crypto-sign-jwt.md): mints the tokens this verifies.
76
+ - [defineSigningKey](../../../config/core/signing-key.md): declares the key.
@@ -0,0 +1,57 @@
1
+ ---
2
+ title: askCryptoVerify
3
+ description: Verify a base64url RS256 signature against a message using a signing key's public half.
4
+ ---
5
+
6
+ # askCryptoVerify
7
+
8
+ Checks that `signature` was produced over `message` by the private half of a [signing key](../../../config/core/signing-key.md). Resolves to `true` or `false`; a signature that is not even decodable is a `MalformedSignature` error rather than `false`, so a wiring bug is distinguishable from a forged token. Most stories want [askCryptoVerifyJwt](./ask-crypto-verify-jwt.md) instead.
9
+
10
+ - **Action type:** `CryptoActionType.Verify`
11
+ - **On AWS:** verified in-process against the key's public half, fetched once per runtime with `kms:GetPublicKey` and cached for an hour. No KMS call on the hot path and no `kms:Verify` grant needed.
12
+ - **On the dev server:** the same check against the locally seeded pair.
13
+
14
+ ```typescript
15
+ import { askCryptoVerify } from 'quidproquo-core';
16
+
17
+ export function* askIsWebhookBodyAuthentic(body: string, signature: string) {
18
+ return yield* askCryptoVerify('webhook-signing-key', body, signature);
19
+ }
20
+ ```
21
+
22
+ ## Signature
23
+
24
+ ```typescript
25
+ function* askCryptoVerify(
26
+ keyName: string,
27
+ message: string,
28
+ signature: string,
29
+ ): AskResponse<boolean>;
30
+ ```
31
+
32
+ ## Parameters
33
+
34
+ | Parameter | Type | Description |
35
+ | --- | --- | --- |
36
+ | `keyName` | `string` | Name of the signing key, declared with [defineSigningKey](../../../config/core/signing-key.md) (or shared via its `owner` option). |
37
+ | `message` | `string` | The exact string that was signed. |
38
+ | `signature` | `string` | The base64url signature from [askCryptoSign](./ask-crypto-sign.md). |
39
+
40
+ ## Returns
41
+
42
+ `boolean`: `true` when the signature verifies under the key, `false` otherwise (wrong key, altered message, altered signature).
43
+
44
+ ## Errors
45
+
46
+ | Error | Meaning |
47
+ | --- | --- |
48
+ | `askCryptoVerify.errorType.KeyNotConfigured` | No `defineSigningKey` with that name exists in the service config. |
49
+ | `askCryptoVerify.errorType.MalformedSignature` | `signature` is not base64url. Probable encoding bug at the caller, not an attack; do not treat as `false`. |
50
+ | `askCryptoVerify.errorType.KeyUnavailable` | The key exists in config but is disabled, deleted, or access was denied. |
51
+ | `askCryptoVerify.errorType.Throttling` | The provider rate limit was exceeded; back off and retry. |
52
+
53
+ ## Related
54
+
55
+ - [defineSigningKey](../../../config/core/signing-key.md): declares the key this action uses.
56
+ - [askCryptoSign](./ask-crypto-sign.md): produces the signature.
57
+ - [askCryptoVerifyJwt](./ask-crypto-verify-jwt.md): verifies a whole JWT, including its time claims.
@@ -0,0 +1,72 @@
1
+ ---
2
+ title: askFileCopy
3
+ description: Copy a file to another path, possibly on another drive, without the bytes crossing the story.
4
+ ---
5
+
6
+ # askFileCopy
7
+
8
+ Copies an object to another path, possibly on another storage drive, without the bytes passing through your service. On AWS this is a server-side S3 `CopyObject`; locally it's a filesystem copy plus its metadata sidecar. The stored content type and content disposition travel with the copy.
9
+
10
+ - **Action type:** `FileActionType.Copy`
11
+ - **On AWS:** issues a single S3 `CopyObject` request from the source bucket/key to the target bucket/key.
12
+ - **Locally:** copies the file, then best-effort copies its `.qpqmeta.json` sidecar (missing sidecar is not an error).
13
+
14
+ ```typescript
15
+ import { askFileCopy } from 'quidproquo-core';
16
+
17
+ export function* askDuplicateReport() {
18
+ yield* askFileCopy('reports', 'drafts/q1.csv', 'reports', 'published/q1.csv');
19
+ }
20
+ ```
21
+
22
+ ## Signature
23
+
24
+ ```typescript
25
+ function* askFileCopy(
26
+ sourceDrive: string,
27
+ sourceFilepath: string,
28
+ targetDrive: string,
29
+ targetFilepath: string,
30
+ scope?: string,
31
+ ): AskResponse<void>;
32
+ ```
33
+
34
+ ## Parameters
35
+
36
+ | Parameter | Type | Description |
37
+ | --- | --- | --- |
38
+ | `sourceDrive` | `string` | Name of the storage drive to copy from — must match a drive declared with [defineStorageDrive](../../../config/core/storage-drive.md) (or one shared via its `owner` option). |
39
+ | `sourceFilepath` | `string` | Path of the file to copy within `sourceDrive`, forward-slash delimited. |
40
+ | `targetDrive` | `string` | Name of the storage drive to copy to. Can be the same as `sourceDrive` or a different one. |
41
+ | `targetFilepath` | `string` | Destination path within `targetDrive`. |
42
+ | `scope` | `string` | Optional storage-scope segment, applied to **both** sides: the processor copies `{scope}/{sourceFilepath}` to `{scope}/{targetFilepath}`. A copy never crosses a storage scope. Must be a single path segment: no separators, `..`, or null bytes. |
43
+
44
+ ## Returns
45
+
46
+ `void` — resolves once the copy completes.
47
+
48
+ ## Errors
49
+
50
+ | Error | Meaning |
51
+ | --- | --- |
52
+ | `FileCopyErrorTypeEnum.AccessDenied` | The caller lacks permission to read the source or write the target. |
53
+ | `FileCopyErrorTypeEnum.DriveNotFound` | The source or target storage drive does not exist. |
54
+ | `FileCopyErrorTypeEnum.FileNotFound` | No object exists at `sourceFilepath`. |
55
+ | `FileCopyErrorTypeEnum.InvalidScope` | `scope` is not a valid single path segment. |
56
+
57
+ Errors thrown by actions can be caught with `askCatch` from quidproquo-core. It returns an `EitherActionResult` — `{ success: true, result }` on success, or `{ success: false, error }` on failure:
58
+
59
+ ```typescript
60
+ const outcome = yield* askCatch(askFileCopy('reports', 'drafts/q1.csv', 'reports', 'published/q1.csv'));
61
+
62
+ if (!outcome.success) {
63
+ // outcome.error.errorType / outcome.error.errorText
64
+ }
65
+ ```
66
+
67
+ ## Related
68
+
69
+ - [defineStorageDrive](../../../config/core/storage-drive.md) — declares the drives this action reads from and writes to.
70
+ - [askFileDelete](./ask-file-delete.md) — remove files from a drive.
71
+ - [askFileExists](./ask-file-exists.md) — check whether a file is present before copying.
72
+ - [askEventDocCopyAsset](../../features/event-doc/ask-event-doc-copy-asset.md) — the event-doc feature built on this action.
@@ -0,0 +1,60 @@
1
+ ---
2
+ title: askEventDocCopyAsset
3
+ description: Copy an asset from another collection's document onto one of this collection's documents as a new immutable asset.
4
+ ---
5
+
6
+ # askEventDocCopyAsset
7
+
8
+ Copies an asset from a document in another collection (`sourceStoreName`) onto `targetDocId` in **this** collection (the provided store context) as a new immutable asset, and returns its ref. The bytes never cross the story — it's a server-side copy — and the stored mimetype comes with them. Both sides resolve under the ambient storage scope, so a copy never crosses a tenant partition.
9
+
10
+ - **Requires the store context** — provide it via [askEventDocProvideStore](./ask-event-doc-provide-store.md) / [askEventDocProvideStoreFromGlobals](./ask-event-doc-provide-store.md#askeventdocprovidestorefromglobals). The target drive is resolved from that context; the source drive is derived from `sourceStoreName`.
11
+ - **Built from:** [askFileCopy](../../core/file/ask-file-copy.md) plus [askNewGuid](../../core/guid/ask-new-guid.md) — not a single action.
12
+ - Also reachable as `EventDocBackend.askCopyAssetFrom` on a backend created by `createEventDocBackend`, which runs it under that backend's own store.
13
+
14
+ ```typescript
15
+ import { askEventDocCopyAsset } from 'quidproquo-features';
16
+
17
+ export function* duplicateCoverImage(sourceDocId: string, cover: EventDocAssetRef, targetDocId: string) {
18
+ const ref = yield* askEventDocCopyAsset('renderGroups', sourceDocId, cover, targetDocId, 'cover.png');
19
+ // record ref.guid in a domain event, e.g. via askEventDocAppendServerEvent
20
+ return ref;
21
+ }
22
+ ```
23
+
24
+ ## Signature
25
+
26
+ ```typescript
27
+ function* askEventDocCopyAsset(
28
+ sourceStoreName: string,
29
+ sourceDocId: string,
30
+ sourceAsset: EventDocAssetRef,
31
+ targetDocId: string,
32
+ targetFilename?: string,
33
+ ): AskResponse<EventDocAssetRef>;
34
+ ```
35
+
36
+ ## Parameters
37
+
38
+ | Parameter | Type | Description |
39
+ | --- | --- | --- |
40
+ | `sourceStoreName` | `string` | The store name of the collection the asset is being copied from — used to derive its storage drive. |
41
+ | `sourceDocId` | `string` | The document owning the source asset in that collection. |
42
+ | `sourceAsset` | `EventDocAssetRef` | Reference to the asset to copy — `{ guid, filename, mimetype }`. |
43
+ | `targetDocId` | `string` | The document in this collection the copy is attached to — determines the new blob's `<docId>/assets/<guid>` key. |
44
+ | `targetFilename` | `string` | Optional filename for the copy's ref. Defaults to `sourceAsset.filename` when omitted. |
45
+
46
+ ## Returns
47
+
48
+ `AskResponse<EventDocAssetRef>` — `{ guid, filename, mimetype }`, a first-class reference to record in a domain event on the target document. `guid` is freshly minted; `mimetype` is carried over from `sourceAsset`.
49
+
50
+ ## Notes
51
+
52
+ - Resolves the target drive from the store context, so a missing context throws (see [askEventDocResolveStore](./ask-event-doc-provide-store.md#askeventdocresolvestore)).
53
+ - The copy is immutable like any other asset: a fresh guid, independent of the source going forward.
54
+
55
+ ## Related
56
+
57
+ - [askFileCopy](../../core/file/ask-file-copy.md) — the core action this is built on.
58
+ - [askEventDocWriteAsset](./ask-event-doc-generate-asset-upload-url.md#askeventdocwriteasset) — write a server-held asset directly instead of copying one.
59
+ - [askEventDocGenerateAssetUploadUrl](./ask-event-doc-generate-asset-upload-url.md) — the presigned-upload counterpart for client-supplied bytes.
60
+ - [askEventDocProvideStore](./ask-event-doc-provide-store.md) — provides the store context this requires.
@@ -50,4 +50,5 @@ The name of the key, and its `uniqueKey` within the config. This is the name you
50
50
 
51
51
  - [askCryptoEncrypt](../../actions/core/crypto/ask-crypto-encrypt.md): encrypts with this key.
52
52
  - [askCryptoDecrypt](../../actions/core/crypto/ask-crypto-decrypt.md): decrypts with this key.
53
+ - [defineSigningKey](./signing-key.md): for signing and verifying (tokens, webhooks) rather than encrypting.
53
54
  - [defineSecret](./secret.md): for platform-level secret values set out-of-band, rather than values your app encrypts itself.
@@ -0,0 +1,54 @@
1
+ ---
2
+ title: defineSigningKey
3
+ description: Define a signing key, an asymmetric RS256 key pair for askCryptoSign / askCryptoVerify / askCryptoGetPublicKey (an RSA KMS key on AWS).
4
+ ---
5
+
6
+ # defineSigningKey
7
+
8
+ Declares a **signing key**: a named RSA-2048 key pair stories use through [askCryptoSign](../../actions/core/crypto/ask-crypto-sign.md), [askCryptoVerify](../../actions/core/crypto/ask-crypto-verify.md) and [askCryptoGetPublicKey](../../actions/core/crypto/ask-crypto-get-public-key.md), and at the JWT level through [askCryptoSignJwt](../../actions/core/crypto/ask-crypto-sign-jwt.md) and [askCryptoVerifyJwt](../../actions/core/crypto/ask-crypto-verify-jwt.md). The private half is generated by the provider at deploy time and never leaves it: nothing to seed after a deploy, nothing to paste into a secret, and nothing that can end up in a story log.
9
+
10
+ - **On AWS:** provisions an asymmetric KMS key (`RSA_2048`, `SIGN_VERIFY`) addressed by a deterministic alias derived from application/module/environment, and grants the service's role `kms:GetPublicKey` and `kms:DescribeKey` on every key it declares, plus `kms:Sign` on the keys it **owns** only - a service that declares another service's key with `owner` can verify but never sign. Verification runs in-process against the public key (fetched once per runtime and cached), so `askCryptoVerify` costs no KMS call on the hot path and no `kms:Verify` grant.
11
+ - **On the dev server:** a local RSA pair is generated on first use at `.qpq-runtime/<app>/signingKeys/<service>.json`, so everything works offline with no AWS credentials.
12
+
13
+ Signing keys are separate from [crypto keys](./crypto-key.md): a provider key is either encrypt/decrypt or sign/verify, never both. Use a crypto key to protect data you store, and a signing key to mint tokens other parties verify.
14
+
15
+ ```typescript
16
+ import { defineSigningKey } from 'quidproquo-core';
17
+
18
+ export default [
19
+ defineSigningKey('access-token-key'),
20
+ ];
21
+ ```
22
+
23
+ ## Signature
24
+
25
+ ```typescript
26
+ function defineSigningKey(
27
+ keyName: string,
28
+ options?: QPQConfigAdvancedSigningKeySettings,
29
+ ): SigningKeyQPQConfigSetting;
30
+ ```
31
+
32
+ ## Parameters
33
+
34
+ ### `keyName`: `string` (required)
35
+
36
+ The name of the key, and its `uniqueKey` within the config. This is the name you pass to the signing actions.
37
+
38
+ ### `options`: `QPQConfigAdvancedSigningKeySettings` (optional)
39
+
40
+ | Property | Type | Default | Description |
41
+ | --- | --- | --- | --- |
42
+ | `owner` | `CrossModuleOwner<'signingKeyName'>` | – | Declares that the key is owned by **another** module/service, so this service is granted use of it rather than creating its own. `{ module, application, feature, environment, signingKeyName }`, all optional; unset parts default to the current service. A service that only verifies tokens another service issues declares the key this way. |
43
+
44
+ ## Notes
45
+
46
+ - The algorithm is fixed at RS256 (RSASSA-PKCS1-v1_5 over SHA-256), the JWT default for RSA keys. It is deterministic: the same key and message always produce the same signature.
47
+ - KMS does not rotate asymmetric keys automatically. Rotating means deploying a new key under the same alias; verifiers pick the new public key up on their next refresh (within an hour on a warm runtime, immediately on a cold one). Tokens signed by the old key stop verifying, so rotate with token lifetimes in mind.
48
+ - Dev keys are random per checkout, so a token signed locally never verifies in a deployed environment, and vice versa.
49
+
50
+ ## Related
51
+
52
+ - [askCryptoSign](../../actions/core/crypto/ask-crypto-sign.md), [askCryptoVerify](../../actions/core/crypto/ask-crypto-verify.md), [askCryptoGetPublicKey](../../actions/core/crypto/ask-crypto-get-public-key.md): the raw signature actions.
53
+ - [askCryptoSignJwt](../../actions/core/crypto/ask-crypto-sign-jwt.md), [askCryptoVerifyJwt](../../actions/core/crypto/ask-crypto-verify-jwt.md): JWT helpers built on them.
54
+ - [defineCryptoKey](./crypto-key.md): for encrypting data rather than signing it.