@pikku/core 0.12.74 → 0.12.77

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 (143) hide show
  1. package/CHANGELOG.md +378 -0
  2. package/dist/column-form.d.ts +32 -0
  3. package/dist/column-form.js +42 -0
  4. package/dist/crypto-utils.d.ts +15 -4
  5. package/dist/crypto-utils.js +18 -2
  6. package/dist/data-classification.d.ts +44 -0
  7. package/dist/function/functions.types.d.ts +23 -10
  8. package/dist/function/index.d.ts +1 -1
  9. package/dist/index.d.ts +6 -3
  10. package/dist/index.js +3 -1
  11. package/dist/middleware/auth-bearer.js +2 -1
  12. package/dist/middleware/remote-auth.js +1 -1
  13. package/dist/remote.js +1 -1
  14. package/dist/secret-value.d.ts +56 -0
  15. package/dist/secret-value.js +46 -0
  16. package/dist/services/audit-service.d.ts +74 -4
  17. package/dist/services/audit-service.js +7 -5
  18. package/dist/services/credential-wire-service.d.ts +5 -0
  19. package/dist/services/credential-wire-service.js +9 -1
  20. package/dist/services/email-service.d.ts +2 -1
  21. package/dist/services/index.d.ts +3 -3
  22. package/dist/services/index.js +1 -1
  23. package/dist/services/local-content-request-handler.d.ts +29 -0
  24. package/dist/services/local-content-request-handler.js +176 -0
  25. package/dist/services/local-secrets.d.ts +4 -3
  26. package/dist/services/local-secrets.js +7 -3
  27. package/dist/services/logger.d.ts +22 -5
  28. package/dist/services/queue-webhook-service.js +1 -1
  29. package/dist/services/scoped-secret-service.d.ts +4 -3
  30. package/dist/services/secret-service.d.ts +8 -3
  31. package/dist/services/typed-secret-service.d.ts +5 -4
  32. package/dist/services/webhook-service.d.ts +2 -1
  33. package/dist/testing/service-tests.js +6 -6
  34. package/dist/types/core.types.d.ts +25 -4
  35. package/dist/wirings/ai-agent/ai-agent-agui.js +13 -1
  36. package/dist/wirings/ai-agent/ai-agent-prepare.js +7 -1
  37. package/dist/wirings/ai-agent/ai-agent-runner.js +14 -2
  38. package/dist/wirings/ai-agent/ai-agent-stream.js +27 -1
  39. package/dist/wirings/ai-agent/ai-agent.types.d.ts +40 -0
  40. package/dist/wirings/ai-agent/index.d.ts +1 -1
  41. package/dist/wirings/ai-agent/index.js +1 -1
  42. package/dist/wirings/ai-agent/voice-input.d.ts +20 -0
  43. package/dist/wirings/ai-agent/voice-input.js +44 -9
  44. package/dist/wirings/ai-agent/voice-output.d.ts +15 -0
  45. package/dist/wirings/ai-agent/voice-output.js +10 -1
  46. package/dist/wirings/cli/channel/cli-raw-client-runner.d.ts +21 -3
  47. package/dist/wirings/cli/channel/cli-raw-client-runner.js +13 -5
  48. package/dist/wirings/cli/channel/index.d.ts +1 -0
  49. package/dist/wirings/persona/define-personas.d.ts +4 -0
  50. package/dist/wirings/persona/define-personas.js +4 -0
  51. package/dist/wirings/persona/persona.types.d.ts +11 -0
  52. package/dist/wirings/queue/queue-identity.js +2 -1
  53. package/dist/wirings/queue/queue.types.d.ts +2 -1
  54. package/dist/wirings/queue/signed-queue-service.d.ts +2 -1
  55. package/dist/wirings/rpc/remote-addon-auth.d.ts +2 -1
  56. package/dist/wirings/rpc/remote-addon-auth.js +6 -2
  57. package/dist/wirings/virtual-user/index.d.ts +3 -0
  58. package/dist/wirings/virtual-user/index.js +2 -0
  59. package/dist/wirings/virtual-user/prepare-virtual-user-run.d.ts +54 -0
  60. package/dist/wirings/virtual-user/prepare-virtual-user-run.js +49 -0
  61. package/dist/wirings/virtual-user/virtual-user-run-store.d.ts +90 -0
  62. package/dist/wirings/virtual-user/virtual-user-run-store.js +1 -0
  63. package/dist/wirings/workflow/dsl/workflow-dsl.types.d.ts +14 -10
  64. package/dist/wirings/workflow/pikku-scenario-service.js +1 -2
  65. package/dist/wirings/workflow/scenario-prose.js +1 -1
  66. package/dist/wirings/workflow/scenario-step.types.d.ts +13 -7
  67. package/dist/wirings/workflow/workflow.types.d.ts +7 -0
  68. package/knowledge/decisions/internals/a-virtual-user-run-is-not-a-workflow-and-not-a-queued-job.md +48 -0
  69. package/knowledge/decisions/internals/core-column-form-is-an-axis-of-its-own.md +84 -0
  70. package/knowledge/decisions/internals/core-data-classification-brand-is-an-optional-property.md +9 -2
  71. package/knowledge/decisions/internals/index.md +4 -0
  72. package/knowledge/decisions/internals/one-project-shape-check-two-validators.md +53 -0
  73. package/knowledge/decisions/internals/scenarios-live-in-files-named-for-them.md +48 -0
  74. package/knowledge/decisions/internals/validate-checks-personas-through-a-shared-module.md +43 -0
  75. package/package.json +3 -2
  76. package/src/column-form.test.ts +97 -0
  77. package/src/column-form.ts +58 -0
  78. package/src/crypto-utils.ts +25 -6
  79. package/src/data-classification.ts +44 -0
  80. package/src/function/functions.types.ts +47 -10
  81. package/src/function/index.ts +1 -0
  82. package/src/index.ts +24 -2
  83. package/src/middleware/auth-bearer.test.ts +3 -2
  84. package/src/middleware/auth-bearer.ts +2 -1
  85. package/src/middleware/remote-auth.test.ts +2 -1
  86. package/src/middleware/remote-auth.ts +1 -1
  87. package/src/remote.test.ts +2 -1
  88. package/src/remote.ts +1 -1
  89. package/src/secret-value.test.ts +204 -0
  90. package/src/secret-value.ts +111 -0
  91. package/src/services/audit-service.ts +87 -9
  92. package/src/services/credential-wire-service.ts +9 -1
  93. package/src/services/email-service.ts +3 -1
  94. package/src/services/index.ts +3 -3
  95. package/src/services/local-content-request-handler.test.ts +202 -0
  96. package/src/services/local-content-request-handler.ts +267 -0
  97. package/src/services/local-secrets.test.ts +20 -5
  98. package/src/services/local-secrets.ts +15 -7
  99. package/src/services/logger.ts +27 -7
  100. package/src/services/queue-webhook-service.test.ts +2 -1
  101. package/src/services/queue-webhook-service.ts +1 -1
  102. package/src/services/scoped-secret-service.ts +4 -3
  103. package/src/services/secret-service.ts +8 -3
  104. package/src/services/typed-secret-service.ts +11 -7
  105. package/src/services/webhook-service.ts +4 -1
  106. package/src/testing/service-tests.ts +6 -6
  107. package/src/types/core.types.ts +25 -4
  108. package/src/wirings/ai-agent/ai-agent-agui.test.ts +16 -0
  109. package/src/wirings/ai-agent/ai-agent-agui.ts +14 -1
  110. package/src/wirings/ai-agent/ai-agent-prepare.ts +7 -1
  111. package/src/wirings/ai-agent/ai-agent-runner.ts +18 -2
  112. package/src/wirings/ai-agent/ai-agent-stream.ts +32 -1
  113. package/src/wirings/ai-agent/ai-agent.types.ts +45 -1
  114. package/src/wirings/ai-agent/index.ts +2 -0
  115. package/src/wirings/ai-agent/voice-input.test.ts +65 -0
  116. package/src/wirings/ai-agent/voice-input.ts +48 -9
  117. package/src/wirings/ai-agent/voice-output.test.ts +91 -1
  118. package/src/wirings/ai-agent/voice-output.ts +28 -1
  119. package/src/wirings/cli/channel/cli-raw-client-runner.ts +39 -9
  120. package/src/wirings/cli/channel/index.ts +4 -0
  121. package/src/wirings/persona/define-personas.ts +4 -0
  122. package/src/wirings/persona/persona.types.ts +11 -0
  123. package/src/wirings/queue/queue-identity.test.ts +2 -1
  124. package/src/wirings/queue/queue-identity.ts +4 -1
  125. package/src/wirings/queue/queue.types.ts +6 -1
  126. package/src/wirings/queue/signed-queue-service.ts +2 -1
  127. package/src/wirings/rpc/remote-addon-auth.ts +8 -3
  128. package/src/wirings/rpc/rpc-runner.test.ts +6 -4
  129. package/src/wirings/virtual-user/index.ts +12 -0
  130. package/src/wirings/virtual-user/prepare-virtual-user-run.test.ts +115 -0
  131. package/src/wirings/virtual-user/prepare-virtual-user-run.ts +95 -0
  132. package/src/wirings/virtual-user/virtual-user-run-store.ts +98 -0
  133. package/src/wirings/workflow/dsl/workflow-dsl.types.ts +14 -16
  134. package/src/wirings/workflow/pikku-scenario-service.ts +1 -10
  135. package/src/wirings/workflow/scenario-prose.test.ts +5 -7
  136. package/src/wirings/workflow/scenario-prose.ts +1 -1
  137. package/src/wirings/workflow/scenario-service.test.ts +0 -1
  138. package/src/wirings/workflow/scenario-step.test.ts +4 -5
  139. package/src/wirings/workflow/scenario-step.types.ts +13 -7
  140. package/src/wirings/workflow/scenario-surface.test.ts +6 -5
  141. package/src/wirings/workflow/workflow.types.ts +7 -0
  142. package/tsconfig.tsbuildinfo +1 -1
  143. package/tsconfig.type-tests.json +12 -0
@@ -1,4 +1,4 @@
1
1
  export { addFunction, getAllFunctionNames } from './function-runner.js';
2
2
  export { pikkuAuth, pikkuPermission, pikkuPermissionFactory, pikkuApprovalDescription, } from './functions.types.js';
3
- export type { CorePikkuFunction, CorePikkuFunctionSessionless, CorePikkuFunctionConfig, CorePikkuAuth, CorePikkuAuthConfig, CorePikkuPermission, } from './functions.types.js';
3
+ export type { CorePikkuFunction, CorePikkuFunctionSessionless, CorePikkuFunctionConfig, CorePikkuSessionlessFunctionConfig, CorePikkuAuth, CorePikkuAuthConfig, CorePikkuPermission, } from './functions.types.js';
4
4
  export type { ListInput, ListOutput, Filter, LeafFilter, LeafValue, } from './list.types.js';
package/dist/index.d.ts CHANGED
@@ -36,8 +36,8 @@ export type { GatewayService } from './services/gateway-service.js';
36
36
  export type { TriggerService } from './services/trigger-service.js';
37
37
  export type { SchemaService } from './services/schema-service.js';
38
38
  export type { SessionService } from './services/user-session-service.js';
39
- export { NoopAuditService, createInvocationAudit, resolveAuditActorFromWire, resolveAuditConfig, } from './services/audit-service.js';
40
- export type { AuditActor, AuditConfig, AuditDurability, AuditEvent, AuditEventBatch, AuditLog, AuditLogWriteInput, AuditOutcome, AuditService, AuditSource, ResolvedAuditConfig, } from './services/audit-service.js';
39
+ export { NoopAuditService, createInvocationAudit, resolveAuditConfig, resolveAuditUserIdentityFromWire, } from './services/audit-service.js';
40
+ export type { AuditConfig, AuditDurability, AuditEvent, AuditEventBatch, AuditFacets, AuditLog, AuditLogWriteInput, AuditOutcome, AuditQuery, AuditQueryResult, AuditService, AuditSource, AuditUserIdentity, ResolvedAuditConfig, } from './services/audit-service.js';
41
41
  export type { AIAgentRunnerService, AIEmbedManyParams, AIEmbedManyResult, AIEmbedParams, AIEmbedResult, AIGenerateImageParams, AIGenerateImagePrompt, AIGenerateImageResult, AIGenerateSpeechParams, AIGenerateSpeechResult, AIProviderOptions, AIRerankParams, AIRerankResult, AITranscriptionParams, AITranscriptionResult, } from './services/ai-agent-runner-service.js';
42
42
  export type { AIEmbeddingService } from './services/ai-embedding-service.js';
43
43
  export type { AIRunStateService } from './services/ai-run-state-service.js';
@@ -59,4 +59,7 @@ export { getSingletonServices, getCreateWireServices, setSingletonServices, } fr
59
59
  export { clearPikkuRuntimeState } from './test-utils.js';
60
60
  export { type ScheduledTaskInfo, type ScheduledTaskSummary, } from './services/scheduler-service.js';
61
61
  export { SchedulerService } from './services/scheduler-service.js';
62
- export type { Private, Pii, Secret, Classification, AnonymizeStrategy, ColumnClassification, ClassificationManifest, } from './data-classification.js';
62
+ export type { Private, Pii, Secret, Classification, AnonymizeStrategy, ColumnClassification, ClassificationManifest, ColumnForm, WrappedValue, SealedValue, HashedValue, } from './data-classification.js';
63
+ export { hashToken, unsafeAsWrapped, unsafeAsSealed, unsafeAsHashed, } from './column-form.js';
64
+ export type { SecretValue, Safe } from './secret-value.js';
65
+ export { createSecretValue, isSecretValue, SecretCoercionError, REDACTED, } from './secret-value.js';
package/dist/index.js CHANGED
@@ -15,7 +15,7 @@ export { AIProviderAuthError, AIProviderNotConfiguredError, BadGatewayError, Bad
15
15
  export { PikkuError, isExpectedError } from './errors/error-handler.js';
16
16
  export { SecretAccessDeniedError, withoutSecrets, } from './services/secretless.js';
17
17
  export { SecretHostNotAllowedError, assertSecretAllowedForHost, } from './services/secret-host-binding.js';
18
- export { NoopAuditService, createInvocationAudit, resolveAuditActorFromWire, resolveAuditConfig, } from './services/audit-service.js';
18
+ export { NoopAuditService, createInvocationAudit, resolveAuditConfig, resolveAuditUserIdentityFromWire, } from './services/audit-service.js';
19
19
  export { createGraph } from './wirings/workflow/graph/graph-node.js';
20
20
  export { wireAddon } from './wirings/rpc/wire-addon.js';
21
21
  export { wireRemoteAddon } from './wirings/rpc/wire-remote-addon.js';
@@ -26,3 +26,5 @@ export { isSerializable, stopSingletonServices, pikkuServerLifecycle, } from './
26
26
  export { getSingletonServices, getCreateWireServices, setSingletonServices, } from './pikku-state.js';
27
27
  export { clearPikkuRuntimeState } from './test-utils.js';
28
28
  export { SchedulerService } from './services/scheduler-service.js';
29
+ export { hashToken, unsafeAsWrapped, unsafeAsSealed, unsafeAsHashed, } from './column-form.js';
30
+ export { createSecretValue, isSecretValue, SecretCoercionError, REDACTED, } from './secret-value.js';
@@ -33,9 +33,10 @@ export const authBearer = pikkuMiddlewareFactory(({ token } = {}) => pikkuMiddle
33
33
  }
34
34
  else {
35
35
  // An unset secret means the feature is off — never a request error.
36
- expected = await secrets
36
+ const stored = await secrets
37
37
  ?.getSecret(token.secretId)
38
38
  .catch(() => undefined);
39
+ expected = stored?.reveal();
39
40
  }
40
41
  if (expected && constantTimeEqual(bearerToken, expected)) {
41
42
  userSession = token.userSession;
@@ -7,7 +7,7 @@ export const pikkuRemoteAuthMiddleware = pikkuMiddleware(async ({ secrets, jwt }
7
7
  }
8
8
  let secret;
9
9
  try {
10
- secret = await secrets.getSecret('PIKKU_REMOTE_SECRET');
10
+ secret = (await secrets.getSecret('PIKKU_REMOTE_SECRET')).reveal();
11
11
  }
12
12
  catch {
13
13
  if (http.request.path().startsWith('/remote/rpc/')) {
package/dist/remote.js CHANGED
@@ -11,7 +11,7 @@ export async function buildRemoteHeaders(jwt, secrets, funcName, session, traceI
11
11
  };
12
12
  let secret;
13
13
  try {
14
- secret = await secrets?.getSecret('PIKKU_REMOTE_SECRET');
14
+ secret = (await secrets?.getSecret('PIKKU_REMOTE_SECRET'))?.reveal();
15
15
  }
16
16
  catch { }
17
17
  if (secret && jwt) {
@@ -0,0 +1,56 @@
1
+ import type { Secret } from './data-classification.js';
2
+ declare const secretValueBrand: unique symbol;
3
+ export declare const REDACTED = "[secret]";
4
+ /** Runtime marker, resilient to duplicate copies of core. */
5
+ declare const SECRET_VALUE: unique symbol;
6
+ declare const NODE_INSPECT: unique symbol;
7
+ export declare class SecretCoercionError extends Error {
8
+ constructor();
9
+ }
10
+ /**
11
+ * A vault secret. Nominal, so it is not assignable to `string` and every
12
+ * concretely-typed sink rejects it; `.reveal()` is the one way out, and every
13
+ * call is a deliberate, greppable disclosure.
14
+ *
15
+ * The revealed value carries the erasable `Secret<T>` classification brand, so
16
+ * the inspector can still follow it one hop past the call.
17
+ *
18
+ * Structured serialization redacts to `[secret]` — an audit or log write must
19
+ * stay honest about the field without crashing the request. String coercion
20
+ * throws, because a template literal or concatenation is always a leak.
21
+ */
22
+ export declare class SecretValue<T = string> {
23
+ #private;
24
+ readonly [secretValueBrand]: true;
25
+ readonly [SECRET_VALUE] = true;
26
+ constructor(value: T);
27
+ reveal(): Secret<T>;
28
+ toJSON(): string;
29
+ [NODE_INSPECT](): string;
30
+ toString(): never;
31
+ [Symbol.toPrimitive](): never;
32
+ }
33
+ export declare const createSecretValue: <T>(value: T) => SecretValue<T>;
34
+ export declare const isSecretValue: (value: unknown) => value is SecretValue<unknown>;
35
+ type IsAny<T> = 0 extends 1 & T ? true : false;
36
+ type Passthrough = Function | Date | RegExp | Error | ArrayBuffer | ArrayBufferView;
37
+ /**
38
+ * Rejects a `SecretValue` anywhere in `T`, however deeply nested, by collapsing
39
+ * it to `never`.
40
+ *
41
+ * For sinks whose parameters are `any`, `unknown` or a free generic — loggers,
42
+ * queue payloads, channel messages — where nominality alone cannot help.
43
+ * `any` is passed through untouched: it cannot be guarded, and collapsing it
44
+ * would reject every legitimate call.
45
+ *
46
+ * `Promise`, `Map` and `Set` are recursed into rather than passed through. A
47
+ * mapped type cannot reach what they hold — mapping their keys yields their
48
+ * methods, not their contents — so treating them as opaque let a secret ride
49
+ * through inside one. `Promise` is the case that bites: `getSecret()` returns
50
+ * `Promise<SecretValue<T>>`, so a forgotten `await` would otherwise log a
51
+ * secret.
52
+ */
53
+ export type Safe<T> = IsAny<T> extends true ? T : [Extract<T, SecretValue<any>>] extends [never] ? T extends Promise<infer V> ? Promise<Safe<V>> : T extends Map<infer K, infer V> ? Map<Safe<K>, Safe<V>> : T extends Set<infer V> ? Set<Safe<V>> : T extends Passthrough ? T : T extends object ? {
54
+ [K in keyof T]: Safe<T[K]>;
55
+ } : T : never;
56
+ export {};
@@ -0,0 +1,46 @@
1
+ export const REDACTED = '[secret]';
2
+ /** Runtime marker, resilient to duplicate copies of core. */
3
+ const SECRET_VALUE = Symbol.for('pikku.secretValue');
4
+ const NODE_INSPECT = Symbol.for('nodejs.util.inspect.custom');
5
+ export class SecretCoercionError extends Error {
6
+ constructor() {
7
+ super(`A secret was coerced to a string, which would write it out in the clear. Unwrap it deliberately with .reveal() at the point it reaches the wire.`);
8
+ this.name = 'SecretCoercionError';
9
+ }
10
+ }
11
+ /**
12
+ * A vault secret. Nominal, so it is not assignable to `string` and every
13
+ * concretely-typed sink rejects it; `.reveal()` is the one way out, and every
14
+ * call is a deliberate, greppable disclosure.
15
+ *
16
+ * The revealed value carries the erasable `Secret<T>` classification brand, so
17
+ * the inspector can still follow it one hop past the call.
18
+ *
19
+ * Structured serialization redacts to `[secret]` — an audit or log write must
20
+ * stay honest about the field without crashing the request. String coercion
21
+ * throws, because a template literal or concatenation is always a leak.
22
+ */
23
+ export class SecretValue {
24
+ [SECRET_VALUE] = true;
25
+ #value;
26
+ constructor(value) {
27
+ this.#value = value;
28
+ }
29
+ reveal() {
30
+ return this.#value;
31
+ }
32
+ toJSON() {
33
+ return REDACTED;
34
+ }
35
+ [NODE_INSPECT]() {
36
+ return REDACTED;
37
+ }
38
+ toString() {
39
+ throw new SecretCoercionError();
40
+ }
41
+ [Symbol.toPrimitive]() {
42
+ throw new SecretCoercionError();
43
+ }
44
+ }
45
+ export const createSecretValue = (value) => new SecretValue(value);
46
+ export const isSecretValue = (value) => typeof value === 'object' && value !== null && SECRET_VALUE in value;
@@ -1,5 +1,6 @@
1
1
  import type { CoreUserSession, PikkuWire, PikkuWiringTypes } from '../types/core.types.js';
2
2
  import type { Logger } from './logger.js';
3
+ import type { Safe } from '../secret-value.js';
3
4
  export type AuditDurability = 'best-effort' | 'transactional';
4
5
  export type AuditOutcome = 'success' | 'failed' | 'denied';
5
6
  export type AuditSource = 'auto' | 'explicit';
@@ -9,7 +10,19 @@ export type AuditConfig = boolean | {
9
10
  export type ResolvedAuditConfig = {
10
11
  durability: AuditDurability;
11
12
  };
12
- export type AuditActor = {
13
+ /**
14
+ * Who an event happened under.
15
+ *
16
+ * A user, not an "actor": in pikku an actor is a synthetic person a scenario
17
+ * drives, flagged `actor` on the user row, and the overwhelming majority of
18
+ * audited events are caused by ordinary customers. Naming this `actor` made the
19
+ * synthetic case unsayable — `actor.actor === true` — and implied every
20
+ * recorded action was a test.
21
+ *
22
+ * `pikkuUserId` is the identity pikku resolves for every wire, so it is the one
23
+ * field a signed-out caller still leaves behind.
24
+ */
25
+ export type AuditUserIdentity = {
13
26
  userId?: string;
14
27
  orgId?: string;
15
28
  pikkuUserId?: string;
@@ -26,26 +39,83 @@ export type AuditEvent = {
26
39
  traceId?: string;
27
40
  transactionId?: string | null;
28
41
  queryId?: string | null;
29
- actor?: AuditActor;
42
+ userIdentity?: AuditUserIdentity;
30
43
  input?: unknown;
31
44
  metadata?: Record<string, unknown>;
32
45
  };
33
46
  export type AuditEventBatch = AuditEvent[];
47
+ /**
48
+ * A page of the trail, newest first, narrowed by the filters a reader picked.
49
+ *
50
+ * Every field is a conjunction, and an empty array is not a filter — it is
51
+ * "match nothing", which would otherwise read as "match everything" and quietly
52
+ * widen a scoped query.
53
+ */
54
+ export type AuditQuery = {
55
+ /** Restrict to these users. */
56
+ userIds?: string[];
57
+ /** Restrict to these `AuditEvent['type']` values. */
58
+ types?: string[];
59
+ /** Restrict to one organisation. */
60
+ orgId?: string;
61
+ /** Inclusive lower bound on `occurredAt` (ISO 8601). */
62
+ from?: string;
63
+ /** Exclusive upper bound on `occurredAt` (ISO 8601). */
64
+ to?: string;
65
+ limit?: number;
66
+ offset?: number;
67
+ };
68
+ /**
69
+ * The distinct values present in the trail, for populating filter controls.
70
+ *
71
+ * Computed over the whole trail rather than the current page — a filter list
72
+ * that only offered what the current page happens to show could never be used
73
+ * to reach anything else.
74
+ */
75
+ export type AuditFacets = {
76
+ userIds: string[];
77
+ types: string[];
78
+ };
79
+ export type AuditQueryResult = {
80
+ events: AuditEvent[];
81
+ /** Offset of the next page, or `null` at the end. */
82
+ nextCursor: number | null;
83
+ /** Present only when the caller asked for it — it costs two extra scans. */
84
+ facets?: AuditFacets;
85
+ };
34
86
  export interface AuditService {
35
87
  audit(event: AuditEvent): Promise<void>;
36
88
  write?(batch: AuditEventBatch): Promise<void>;
89
+ /**
90
+ * The read side. Optional because a sink can legitimately be write-only — a
91
+ * queue producer that hands events to another system has nothing to read
92
+ * back. A reader that finds this absent should say the trail is not readable
93
+ * here rather than that it is empty; the two are very different answers.
94
+ */
95
+ query?(query: AuditQuery): Promise<AuditQueryResult>;
96
+ /** Distinct users and types across the whole trail. Paired with {@link query}. */
97
+ facets?(): Promise<AuditFacets>;
37
98
  }
38
99
  export declare class NoopAuditService implements AuditService {
39
100
  audit(_event: AuditEvent): Promise<void>;
40
101
  write(_batch: AuditEventBatch): Promise<void>;
41
102
  }
42
103
  export type AuditLogWriteInput = Omit<AuditEvent, 'occurredAt'>;
104
+ /**
105
+ * The audit an invocation writes to. `write` is `Safe<>`-guarded like the
106
+ * logger: an audit event carries `input` and `metadata` as `unknown`, so
107
+ * nominality alone cannot stop a `SecretValue` landing in one. A secret
108
+ * anywhere in the event, however deeply nested, collapses to `never`.
109
+ *
110
+ * An unrevealed `SecretValue` would serialize as `[secret]` anyway; the guard
111
+ * is what makes that an explicit choice rather than a near miss.
112
+ */
43
113
  export interface AuditLog {
44
114
  readonly config: ResolvedAuditConfig | undefined;
45
- write(event: AuditLogWriteInput): Promise<void>;
115
+ write<E extends AuditLogWriteInput>(event: Safe<E>): Promise<void>;
46
116
  flush(): Promise<void>;
47
117
  close(): Promise<void>;
48
118
  }
49
119
  export declare const resolveAuditConfig: (config?: AuditConfig) => ResolvedAuditConfig | undefined;
50
120
  export declare const createInvocationAudit: (service: AuditService, wire: PikkuWire<any, any, any, CoreUserSession>, logger?: Logger) => AuditLog;
51
- export declare const resolveAuditActorFromWire: (wire: PikkuWire<any, any, any, CoreUserSession>) => AuditActor | undefined;
121
+ export declare const resolveAuditUserIdentityFromWire: (wire: PikkuWire<any, any, any, CoreUserSession>) => AuditUserIdentity | undefined;
@@ -75,7 +75,7 @@ class InvocationAuditLog {
75
75
  wireType: this.wire.wireType,
76
76
  wireId: this.wire.wireId,
77
77
  traceId: this.wire.traceId,
78
- actor: event.actor ?? resolveAuditActorFromWire(this.wire),
78
+ userIdentity: event.userIdentity ?? resolveAuditUserIdentityFromWire(this.wire),
79
79
  ...event,
80
80
  occurredAt: new Date().toISOString(),
81
81
  };
@@ -98,15 +98,17 @@ export const createInvocationAudit = (service, wire, logger) => {
98
98
  }
99
99
  return new InvocationAuditLog(wire.audit, service, wire, logger);
100
100
  };
101
- export const resolveAuditActorFromWire = (wire) => {
101
+ export const resolveAuditUserIdentityFromWire = (wire) => {
102
102
  const session = wire.session;
103
- const actor = {
103
+ const userIdentity = {
104
104
  userId: session?.userId,
105
105
  orgId: session?.orgId,
106
106
  pikkuUserId: wire.pikkuUserId,
107
107
  };
108
- if (!actor.userId && !actor.orgId && !actor.pikkuUserId) {
108
+ if (!userIdentity.userId &&
109
+ !userIdentity.orgId &&
110
+ !userIdentity.pikkuUserId) {
109
111
  return undefined;
110
112
  }
111
- return actor;
113
+ return userIdentity;
112
114
  };
@@ -9,6 +9,11 @@ export declare class PikkuCredentialWireService {
9
9
  private loadPromise;
10
10
  constructor(credentialService?: CredentialService | undefined, wire?: PikkuRawWire | undefined, aliases?: Record<string, string> | undefined);
11
11
  private resolveName;
12
+ /**
13
+ * A credential is one of the few places vault material is meant to end up, so
14
+ * a `SecretValue` is unwrapped here rather than rejected — `get` promises the
15
+ * raw material, and storing the wrapper would make that a lie.
16
+ */
12
17
  set(name: string, value: unknown): void;
13
18
  get<T = unknown>(name: string): T | null | Promise<T | null>;
14
19
  getAll(): Record<string, unknown> | Promise<Record<string, unknown>>;
@@ -1,3 +1,4 @@
1
+ import { isSecretValue } from '../secret-value.js';
1
2
  import { defaultPikkuUserIdResolver } from './pikku-user-id.js';
2
3
  export class PikkuCredentialWireService {
3
4
  credentialService;
@@ -14,8 +15,15 @@ export class PikkuCredentialWireService {
14
15
  resolveName(name) {
15
16
  return this.aliases?.[name] ?? name;
16
17
  }
18
+ /**
19
+ * A credential is one of the few places vault material is meant to end up, so
20
+ * a `SecretValue` is unwrapped here rather than rejected — `get` promises the
21
+ * raw material, and storing the wrapper would make that a lie.
22
+ */
17
23
  set(name, value) {
18
- this.credentials[this.resolveName(name)] = value;
24
+ this.credentials[this.resolveName(name)] = isSecretValue(value)
25
+ ? value.reveal()
26
+ : value;
19
27
  }
20
28
  get(name) {
21
29
  const key = this.resolveName(name);
@@ -1,3 +1,4 @@
1
+ import type { Safe } from '../secret-value.js';
1
2
  export interface EmailTemplateReference {
2
3
  name: string;
3
4
  locale?: string;
@@ -32,5 +33,5 @@ export interface SendEmailResult {
32
33
  messageId?: string;
33
34
  }
34
35
  export interface EmailService {
35
- send(input: SendEmailInput): Promise<SendEmailResult>;
36
+ send<T extends SendEmailInput>(input: Safe<T>): Promise<SendEmailResult>;
36
37
  }
@@ -22,7 +22,7 @@ export type { JWTService } from './jwt-service.js';
22
22
  export type { EmailService, EmailTemplateReference, SendEmailInput, SendEmailResult, SendHTMLEmailInput, SendTemplateEmailInput, SendTextEmailInput, } from './email-service.js';
23
23
  export { DEFAULT_WEBHOOK_RETRIES, DEFAULT_WEBHOOK_SIGNATURE_HEADER, PIKKU_OUTGOING_WEBHOOK_QUEUE_NAME, WebhookService, type SendWebhookInput, type SendWebhookResult, type WebhookAttemptRecord, type WebhookAttemptResult, type WebhookDeliveryRecord, type WebhookDeliveryWithAttempts, type WebhookJobData, type WebhookServiceConfig, } from './webhook-service.js';
24
24
  export type { Logger } from './logger.js';
25
- export type { SecretService } from './secret-service.js';
25
+ export type { SecretService, SecretValues } from './secret-service.js';
26
26
  export type { VariablesService } from './variables-service.js';
27
27
  export type { SchemaService } from './schema-service.js';
28
28
  export type { SessionService } from './user-session-service.js';
@@ -44,8 +44,8 @@ export type { SessionStore } from './session-store.js';
44
44
  export type { ScopeService, Role } from './scope-service.js';
45
45
  export { assertRoleIsMutable, assertRoleNameAvailable, roleLockReason, } from './system-role-guard.js';
46
46
  export type { IsSystemRole } from './system-role-guard.js';
47
- export { NoopAuditService, createInvocationAudit, resolveAuditActorFromWire, resolveAuditConfig, } from './audit-service.js';
48
- export type { AuditActor, AuditConfig, AuditDurability, AuditEvent, AuditEventBatch, AuditLog, AuditLogWriteInput, AuditOutcome, AuditService, AuditSource, ResolvedAuditConfig, } from './audit-service.js';
47
+ export { NoopAuditService, createInvocationAudit, resolveAuditConfig, resolveAuditUserIdentityFromWire, } from './audit-service.js';
48
+ export type { AuditConfig, AuditDurability, AuditEvent, AuditEventBatch, AuditLog, AuditLogWriteInput, AuditOutcome, AuditService, AuditSource, AuditUserIdentity, ResolvedAuditConfig, } from './audit-service.js';
49
49
  export { InMemorySessionStore } from './in-memory-session-store.js';
50
50
  export type { MCPMeta, RPCMetaRecord, ServiceMeta, ServicesMetaRecord, MiddlewareDefinitionMeta, MiddlewareInstanceMeta, GroupMeta, MiddlewareGroupsMeta, PermissionDefinitionMeta, PermissionsGroupsMeta, FunctionsMeta, FunctionMeta, MiddlewareMeta, PermissionMeta, AgentsMeta, AgentMeta, EmailsMeta, EmailTemplateMeta, EmailTemplateLocaleMeta, EmailTemplateAssets, } from './meta-service.js';
51
51
  export type { CoverageService, CoverageSnapshot, LineHits, ScriptCoverage, FunctionCoverage, CoverageRange, CoverageStatus, FunctionCoverageEntry, FunctionCoverageReport, CoverageFunctionMeta, } from './v8-coverage-service.js';
@@ -18,7 +18,7 @@ export { LocalGatewayService } from './local-gateway-service.js';
18
18
  export { DEFAULT_WEBHOOK_RETRIES, DEFAULT_WEBHOOK_SIGNATURE_HEADER, PIKKU_OUTGOING_WEBHOOK_QUEUE_NAME, WebhookService, } from './webhook-service.js';
19
19
  export { TypedCredentialService } from './typed-credential-service.js';
20
20
  export { assertRoleIsMutable, assertRoleNameAvailable, roleLockReason, } from './system-role-guard.js';
21
- export { NoopAuditService, createInvocationAudit, resolveAuditActorFromWire, resolveAuditConfig, } from './audit-service.js';
21
+ export { NoopAuditService, createInvocationAudit, resolveAuditConfig, resolveAuditUserIdentityFromWire, } from './audit-service.js';
22
22
  export { InMemorySessionStore } from './in-memory-session-store.js';
23
23
  export { StubTracker, createStubProxy, getStubTracker, isTestRun, stub, spy, } from './stub-tracker.js';
24
24
  export { SecretHostNotAllowedError, assertSecretAllowedForHost, } from './secret-host-binding.js';
@@ -0,0 +1,29 @@
1
+ import type { JWTService, Logger } from '@pikku/core/services';
2
+ import { type LocalContentConfig } from './local-content.js';
3
+ /**
4
+ * The server half of {@link LocalContent}.
5
+ *
6
+ * `LocalContent` hands out `PUT <uploadUrlPrefix>/<key>` upload URLs and signed
7
+ * `GET <assetUrlPrefix>/<key>` read URLs, but it cannot answer either: it is a
8
+ * `ContentService`, not a transport. Something in the serving path has to, and
9
+ * until now only `@pikku/node-http-server` did — so the very same project served
10
+ * under Bun handed the browser upload URLs that 404ed, with nothing naming the
11
+ * cause.
12
+ *
13
+ * Expressed in Web `Request`/`Response` so every runtime can share one
14
+ * implementation rather than each re-deriving the signature check. Returns
15
+ * `null` for anything that is not a content request, which is the caller's
16
+ * signal to carry on with its normal routing.
17
+ */
18
+ export type LocalContentRequestHandler = (request: Request) => Promise<Response | null>;
19
+ export type LocalContentRequestHandlerOptions = {
20
+ content: LocalContentConfig;
21
+ logger: Logger;
22
+ /**
23
+ * Resolved per request rather than passed by value: a runtime may only be
24
+ * able to reach the signing service through `singletonServices`, which is not
25
+ * populated until after the server is constructed.
26
+ */
27
+ getJWT: () => JWTService | undefined;
28
+ };
29
+ export declare const createLocalContentRequestHandler: ({ content, logger, getJWT, }: LocalContentRequestHandlerOptions) => LocalContentRequestHandler;
@@ -0,0 +1,176 @@
1
+ import { createReadStream } from 'fs';
2
+ import { mkdir, stat, writeFile } from 'fs/promises';
3
+ import { normalize, resolve } from 'path';
4
+ import { Readable } from 'stream';
5
+ import { signedContentPath } from './local-content.js';
6
+ const matchesPrefix = (pathname, prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`);
7
+ const contentKey = (pathname, prefix) => pathname.slice(prefix.length).replace(/^\/+/, '');
8
+ /**
9
+ * Resolve a key against the content root, or `null` if it escapes. `normalize`
10
+ * first so `..` segments are collapsed before the prefix check, and the
11
+ * comparison carries a trailing separator so a sibling directory whose name
12
+ * merely starts with the root's cannot pass as being inside it.
13
+ */
14
+ const toTargetPath = (basePath, key) => {
15
+ const normalizedBasePath = resolve(basePath);
16
+ const targetPath = resolve(normalizedBasePath, normalize(key));
17
+ return targetPath.startsWith(`${normalizedBasePath}/`) ? targetPath : null;
18
+ };
19
+ const parseSizeLimit = (sizeLimit) => {
20
+ const match = /^(\d+(?:\.\d+)?)(b|kb|mb|gb)?$/i.exec(sizeLimit.trim());
21
+ if (!match) {
22
+ throw new Error(`Invalid size limit: ${sizeLimit}`);
23
+ }
24
+ const value = Number(match[1]);
25
+ const unit = (match[2] ?? 'b').toLowerCase();
26
+ const multiplier = unit === 'gb'
27
+ ? 1024 * 1024 * 1024
28
+ : unit === 'mb'
29
+ ? 1024 * 1024
30
+ : unit === 'kb'
31
+ ? 1024
32
+ : 1;
33
+ return value * multiplier;
34
+ };
35
+ const text = (status, body) => new Response(body, {
36
+ status,
37
+ headers: { 'content-type': 'text/plain; charset=utf-8' },
38
+ });
39
+ export const createLocalContentRequestHandler = ({ content, logger, getJWT, }) => {
40
+ // Logged at most once. An unverifiable request is attacker-triggerable, so
41
+ // this reports a startup misconfiguration rather than per-request news.
42
+ let loggedMissingJWT = false;
43
+ const validateSignedAssetRequest = async (requestUrl) => {
44
+ const signedAtValue = requestUrl.searchParams.get('signedAt');
45
+ const expiresAtValue = requestUrl.searchParams.get('expiresAt');
46
+ const notBeforeValue = requestUrl.searchParams.get('notBefore');
47
+ const signature = requestUrl.searchParams.get('signature');
48
+ if (!signedAtValue || !expiresAtValue) {
49
+ return { ok: false, status: 403, body: 'Signed URL required' };
50
+ }
51
+ const signedAt = Number(signedAtValue);
52
+ const expiresAt = Number(expiresAtValue);
53
+ const notBefore = notBeforeValue == null ? undefined : Number(notBeforeValue);
54
+ if (!Number.isFinite(signedAt) ||
55
+ !Number.isFinite(expiresAt) ||
56
+ (notBefore != null && !Number.isFinite(notBefore))) {
57
+ return { ok: false, status: 403, body: 'Invalid signed URL' };
58
+ }
59
+ const now = Date.now();
60
+ if (now > expiresAt || (notBefore != null && now < notBefore)) {
61
+ return { ok: false, status: 403, body: 'Signed URL expired' };
62
+ }
63
+ const jwt = getJWT();
64
+ if (!jwt) {
65
+ if (!loggedMissingJWT) {
66
+ loggedMissingJWT = true;
67
+ logger.error('pikku: refusing signed asset reads — no JWTService is available to verify them. Pass `contentSigningJWT` (the same service LocalContent signs with) or expose it as `singletonServices.jwt`.');
68
+ }
69
+ return { ok: false, status: 403, body: 'Invalid signed URL' };
70
+ }
71
+ if (!signature) {
72
+ return { ok: false, status: 403, body: 'Signed URL signature required' };
73
+ }
74
+ try {
75
+ const payload = await jwt.decode(signature);
76
+ // Every claim is compared, the path included: without it a signature
77
+ // minted for one asset would read any other.
78
+ if (payload.signedAt !== signedAt ||
79
+ payload.expiresAt !== expiresAt ||
80
+ payload.notBefore !== notBefore ||
81
+ payload.path !== signedContentPath(requestUrl.pathname)) {
82
+ return { ok: false, status: 403, body: 'Invalid signed URL' };
83
+ }
84
+ }
85
+ catch {
86
+ return { ok: false, status: 403, body: 'Invalid signed URL' };
87
+ }
88
+ return { ok: true };
89
+ };
90
+ const handleUpload = async (request, pathname) => {
91
+ const key = contentKey(pathname, content.uploadUrlPrefix);
92
+ const targetPath = toTargetPath(content.localFileUploadPath, key);
93
+ if (!targetPath) {
94
+ return text(400, 'Invalid path');
95
+ }
96
+ const maxBytes = parseSizeLimit(content.sizeLimit ?? '1mb');
97
+ // Counted as it arrives and abandoned the moment it goes over, so an
98
+ // oversized upload costs the limit rather than its own size — `arrayBuffer()`
99
+ // would have to hold all of it first, which hands an unauthenticated caller
100
+ // a way to spend the server's memory. Mirrors node-http-server's
101
+ // `readRequestBody`, which aborts the same way.
102
+ const chunks = [];
103
+ let bytesRead = 0;
104
+ const reader = request.body?.getReader();
105
+ if (reader) {
106
+ try {
107
+ for (;;) {
108
+ const { done, value } = await reader.read();
109
+ if (done)
110
+ break;
111
+ bytesRead += value.byteLength;
112
+ if (bytesRead > maxBytes) {
113
+ await reader.cancel();
114
+ return text(413, 'Content too large');
115
+ }
116
+ chunks.push(Buffer.from(value));
117
+ }
118
+ }
119
+ finally {
120
+ reader.releaseLock();
121
+ }
122
+ }
123
+ await mkdir(resolve(targetPath, '..'), { recursive: true });
124
+ await writeFile(targetPath, Buffer.concat(chunks));
125
+ return new Response(null, { status: 200 });
126
+ };
127
+ const handleAsset = async (request, requestUrl, pathname) => {
128
+ const key = contentKey(pathname, content.assetUrlPrefix);
129
+ const targetPath = toTargetPath(content.localFileUploadPath, key);
130
+ if (!targetPath) {
131
+ return text(400, 'Invalid path');
132
+ }
133
+ const signed = await validateSignedAssetRequest(requestUrl);
134
+ if (!signed.ok) {
135
+ return text(signed.status, signed.body);
136
+ }
137
+ try {
138
+ const file = await stat(targetPath);
139
+ if (!file.isFile()) {
140
+ return new Response(null, { status: 404 });
141
+ }
142
+ const headers = {
143
+ 'content-length': String(file.size),
144
+ 'content-type': 'application/octet-stream',
145
+ };
146
+ if (request.method === 'HEAD') {
147
+ return new Response(null, { status: 200, headers });
148
+ }
149
+ // Streamed rather than buffered: assets are user uploads, and their size
150
+ // is bounded by `sizeLimit` at write time, not by anything here.
151
+ return new Response(Readable.toWeb(createReadStream(targetPath)), { status: 200, headers });
152
+ }
153
+ catch {
154
+ return new Response(null, { status: 404 });
155
+ }
156
+ };
157
+ return async (request) => {
158
+ let requestUrl;
159
+ try {
160
+ requestUrl = new URL(request.url);
161
+ }
162
+ catch {
163
+ return null;
164
+ }
165
+ const pathname = decodeURIComponent(requestUrl.pathname);
166
+ if (request.method === 'PUT' &&
167
+ matchesPrefix(pathname, content.uploadUrlPrefix)) {
168
+ return handleUpload(request, pathname);
169
+ }
170
+ if ((request.method === 'GET' || request.method === 'HEAD') &&
171
+ matchesPrefix(pathname, content.assetUrlPrefix)) {
172
+ return handleAsset(request, requestUrl, pathname);
173
+ }
174
+ return null;
175
+ };
176
+ };
@@ -1,13 +1,14 @@
1
- import type { SecretService } from './secret-service.js';
1
+ import { type SecretValue } from '../secret-value.js';
2
+ import type { SecretService, SecretValues } from './secret-service.js';
2
3
  import type { VariablesService } from './variables-service.js';
3
4
  export declare class LocalSecretService implements SecretService {
4
5
  private variables;
5
6
  private localSecrets;
6
7
  private parseSecret;
7
8
  constructor(variables?: VariablesService);
8
- getSecret<T = string>(key: string): Promise<T>;
9
+ getSecret<T = string>(key: string): Promise<SecretValue<T>>;
9
10
  setSecret(key: string, value: unknown): Promise<void>;
10
11
  hasSecret(key: string): Promise<boolean>;
11
12
  deleteSecret(key: string): Promise<void>;
12
- getSecrets<T extends Record<string, unknown> = Record<string, unknown>>(keys: (keyof T & string)[]): Promise<Partial<T>>;
13
+ getSecrets<T extends Record<string, unknown> = Record<string, unknown>>(keys: (keyof T & string)[]): Promise<Partial<SecretValues<T>>>;
13
14
  }