create-qpq-app 0.1.23 → 0.1.25
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/ai/ask-ai-prompt-stream.md +2 -2
- package/template/docusaurus/docs/actions/core/ai/ask-ai-prompt.md +3 -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/core/system/ask-get-runtime-remaining-time.md +52 -0
- package/template/docusaurus/docs/actions/features/event-doc/ask-event-doc-copy-asset.md +60 -0
- package/template/docusaurus/docs/actions/features/event-doc/ask-event-doc-event-append.md +3 -3
- package/template/docusaurus/docs/actions/features/event-doc-ai/active-chat-state.md +3 -2
- package/template/docusaurus/docs/actions/features/event-doc-ai/ask-event-doc-ai-process-continue.md +46 -0
- package/template/docusaurus/docs/actions/features/event-doc-ai/ask-event-doc-ai-process-send.md +6 -25
- package/template/docusaurus/docs/actions/features/event-doc-ai/ask-event-doc-ai-stream-turn.md +67 -0
- package/template/docusaurus/docs/actions/features/event-doc-ai/chat-list-state.md +2 -1
- package/template/docusaurus/docs/actions/features/event-doc-ai/streaming-and-status-state.md +7 -6
- package/template/docusaurus/docs/actions/features/web-socket-queue/ask-service-request.md +15 -0
- package/template/docusaurus/docs/config/core/crypto-key.md +1 -0
- package/template/docusaurus/docs/config/core/signing-key.md +54 -0
- package/template/docusaurus/docs/config/core/storage-drive.md +1 -0
- package/template/docusaurus/docs/config/features/event-doc-ai.md +4 -2
- package/template/docusaurus/docs/config/features/event-doc.md +2 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-qpq-app",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.25",
|
|
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.25"
|
|
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
|
+
}
|
|
@@ -42,7 +42,7 @@ The parameters are identical to [askAiPrompt](./ask-ai-prompt.md) — see there
|
|
|
42
42
|
| --- | --- | --- |
|
|
43
43
|
| `model` | `AiModel` | Which model to prompt. |
|
|
44
44
|
| `prompt` | `string` | The user prompt. Ignored when `options.messages` is set. |
|
|
45
|
-
| `options` | `AskAiPromptStreamOptions` | `{ system?, aiName?, messages?, reasoning?, caching? }` — same shape and meaning as [`AskAiPromptOptions`](./ask-ai-prompt.md#askaipromptoptions). |
|
|
45
|
+
| `options` | `AskAiPromptStreamOptions` | `{ system?, aiName?, messages?, reasoning?, caching?, maxDurationMs?, maxSteps?, maxOutputTokens? }` — same shape and meaning as [`AskAiPromptOptions`](./ask-ai-prompt.md#askaipromptoptions). |
|
|
46
46
|
|
|
47
47
|
## Returns
|
|
48
48
|
|
|
@@ -92,7 +92,7 @@ Within a step, text / reasoning / tool-input events arrive as matched `*Start
|
|
|
92
92
|
| `stop` | `stop` | The model completed its answer naturally. |
|
|
93
93
|
| `length` | `length` | The response hit the output token limit. |
|
|
94
94
|
| `contentFilter` | `content-filter` | The provider's content filter stopped the response. |
|
|
95
|
-
| `toolCalls` | `tool-calls` | Generation stopped while the model still had tool calls in flight. On the final `Finish` part this means the turn was halted early by a stop condition (
|
|
95
|
+
| `toolCalls` | `tool-calls` | Generation stopped while the model still had tool calls in flight. On the final `Finish` part this means the turn was halted early by a stop condition (`maxSteps` or `maxDurationMs`) rather than finishing naturally, and can be resumed by re-sending the recorded history. |
|
|
96
96
|
| `error` | `error` | The stream errored. |
|
|
97
97
|
| `other` | `other` | The provider reported a reason outside this catalog. |
|
|
98
98
|
| `unknown` | `unknown` | The provider reported no reason, or one this version does not recognise. |
|
|
@@ -50,6 +50,9 @@ function* askAiPrompt(
|
|
|
50
50
|
| `messages` | [`AiMessage[]`](#aimessage) | – | A full conversation history. When present, this is sent instead of `prompt`, letting you carry a multi-turn dialogue (including prior assistant turns and tool results). |
|
|
51
51
|
| `reasoning` | [`AiReasoningConfig`](#aireasoningconfig) | – | Enables extended thinking. Its presence turns reasoning on; `budgetTokens` caps how many tokens the model may spend thinking before it answers (defaults to `4096` on AWS). |
|
|
52
52
|
| `caching` | `boolean` | – | Marks the system prompt and the last message (or the last `messages` entry) with a Bedrock cache point, so a following call in the same conversation can read everything up to there from cache instead of reprocessing it. |
|
|
53
|
+
| `maxSteps` | `number` | – | Cap on model/tool steps in one call. Unset means no cap: the loop runs until the model stops on its own or `maxDurationMs` trips. A client-side tool call (a tool with no executor) still halts it immediately. |
|
|
54
|
+
| `maxOutputTokens` | `number` | provider default | Output token cap per model call. Bedrock defaults to 8192, which a reasoning block plus a large tool input can exceed; the step then finishes with `length` and the tool call arrives truncated. Raise it for agentic workloads (Claude Sonnet allows 64k). |
|
|
55
|
+
| `maxDurationMs` | `number` | – | Wall-clock budget for the tool loop. Checked between steps, so the loop can overrun by one step; leave headroom. When it trips with tool calls still outstanding the result finishes with `toolCalls`, and re-sending the recorded history resumes the turn. Pair it with [askGetRuntimeRemainingTime](../system/ask-get-runtime-remaining-time.md) to stop before the platform deadline. |
|
|
53
56
|
|
|
54
57
|
### `AiModel`
|
|
55
58
|
|
|
@@ -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.
|