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.
- package/package.json +2 -2
- package/template/apps/qpqjs/packages/constants/src/smokeProbe.ts +5 -0
- package/template/apps/qpqjs/services/test/models/src/SmokeSigningKeyClaims.ts +6 -0
- package/template/apps/qpqjs/services/test/models/src/index.ts +1 -0
- package/template/apps/qpqjs/services/test/service/src/smoke/config/defineSmoke.ts +10 -1
- package/template/apps/qpqjs/services/test/service/src/smoke/tests/crossService/askRunCrossServiceSigningKeyTest.ts +60 -0
- package/template/apps/qpqjs/services/test/service/src/smoke/tests/crossService/index.ts +1 -0
- package/template/apps/qpqjs/services/test/service/src/smoke/tests/index.ts +1 -0
- package/template/apps/qpqjs/services/test/service/src/smoke/tests/signingKey/askRunSigningKeyTest.ts +87 -0
- package/template/apps/qpqjs/services/test/service/src/smoke/tests/signingKey/index.ts +1 -0
- package/template/apps/qpqjs/services/test/service/src/smoke/tests/smokeTestRegistry.ts +7 -0
- package/template/apps/qpqjs/services/testa/models/src/CrossServiceSigningKeyProbePayload.ts +5 -0
- package/template/apps/qpqjs/services/testa/models/src/CrossServiceSigningKeyProbeResult.ts +11 -0
- package/template/apps/qpqjs/services/testa/models/src/index.ts +2 -0
- package/template/apps/qpqjs/services/testa/service/src/crossServiceProbe/config/defineCrossServiceProbe.ts +16 -0
- package/template/apps/qpqjs/services/testa/service/src/crossServiceProbe/entry/serviceFunction/index.ts +1 -0
- package/template/apps/qpqjs/services/testa/service/src/crossServiceProbe/entry/serviceFunction/smokeCrossServiceSigningKeyProbe.ts +16 -0
- package/template/apps/qpqjs/services/testa/service/src/crossServiceProbe/logic/askProbeForeignSigningKey.ts +36 -0
- package/template/apps/qpqjs/services/testa/service/src/crossServiceProbe/logic/index.ts +1 -0
- package/template/docusaurus/docs/actions/core/crypto/_category_.json +1 -1
- package/template/docusaurus/docs/actions/core/crypto/ask-crypto-get-public-key.md +51 -0
- package/template/docusaurus/docs/actions/core/crypto/ask-crypto-sign-jwt.md +53 -0
- package/template/docusaurus/docs/actions/core/crypto/ask-crypto-sign.md +54 -0
- package/template/docusaurus/docs/actions/core/crypto/ask-crypto-verify-jwt.md +76 -0
- package/template/docusaurus/docs/actions/core/crypto/ask-crypto-verify.md +57 -0
- package/template/docusaurus/docs/actions/core/file/ask-file-copy.md +72 -0
- package/template/docusaurus/docs/actions/features/event-doc/ask-event-doc-copy-asset.md +60 -0
- package/template/docusaurus/docs/config/core/crypto-key.md +1 -0
- 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.
|
|
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.
|
|
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';
|
|
@@ -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 {
|
|
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
|
+
}
|
package/template/apps/qpqjs/services/test/service/src/smoke/tests/signingKey/askRunSigningKeyTest.ts
ADDED
|
@@ -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,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,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
|
};
|
|
@@ -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
|
+
}
|
|
@@ -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
|
|
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.
|