@flowdular/sdk 0.3.0 → 0.3.2

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 (174) hide show
  1. package/.ai/platform-capabilities.md +4 -2
  2. package/.ai/policies/capabilities.yaml +30 -3
  3. package/.ai/references/catalog/migrations/0005_catalog_list_indexes.down.sql +3 -0
  4. package/.ai/references/catalog/migrations/0005_catalog_list_indexes.up.sql +11 -0
  5. package/.ai/references/catalog/module.json +11 -1
  6. package/.ai/references/catalog/package.json +2 -2
  7. package/.ai/references/catalog/spec/module.yaml +25 -4
  8. package/.ai/references/catalog/src/agent/tools.ts +19 -10
  9. package/.ai/references/catalog/src/api/endpoints.ts +150 -10
  10. package/.ai/references/catalog/src/api/list-cursor.ts +83 -0
  11. package/.ai/references/catalog/src/client/CatalogView.tsrx +505 -159
  12. package/.ai/references/catalog/src/client/api.ts +124 -36
  13. package/.ai/references/catalog/src/client/contribution.tsrx +5 -0
  14. package/.ai/references/catalog/src/client/state.ts +169 -3
  15. package/.ai/references/catalog/src/domain/lists.ts +7 -0
  16. package/.ai/references/catalog/src/domain/types.ts +20 -0
  17. package/.ai/references/catalog/src/platform.ts +20 -0
  18. package/.ai/references/catalog/src/services/catalog-service.ts +143 -8
  19. package/.ai/references/catalog/src/services/database-repository.ts +104 -17
  20. package/.ai/references/catalog/src/services/item-export.ts +81 -0
  21. package/.ai/references/catalog/src/services/migration.ts +27 -1
  22. package/.ai/references/catalog/src/services/repository.ts +31 -2
  23. package/.ai/references/catalog/tests/agent-tools.test.ts +6 -5
  24. package/.ai/references/catalog/tests/client-state.test.ts +124 -0
  25. package/.ai/references/catalog/tests/endpoints.test.ts +269 -0
  26. package/.ai/references/catalog/tests/export.test.ts +134 -0
  27. package/.ai/references/catalog/tests/idempotency.test.ts +15 -14
  28. package/.ai/references/catalog/tests/list.test.ts +217 -0
  29. package/.ai/references/catalog/tests/migrations.test.ts +58 -2
  30. package/.ai/references/catalog/tests/module.test.ts +2 -1
  31. package/.ai/references/catalog/tests/support/database.ts +14 -0
  32. package/.ai/references/catalog/translations/en.json +35 -4
  33. package/.ai/references/catalog/translations/pl.json +35 -4
  34. package/.ai/references/catalog.provenance.json +34 -26
  35. package/.ai/skills/cli-extension/SKILL.md +1 -1
  36. package/.ai/skills/deploy-operate/SKILL.md +7 -2
  37. package/README.md +1 -1
  38. package/assets/flowdular-banner.webp +0 -0
  39. package/modules/access/module.json +1 -1
  40. package/modules/access/spec/module.yaml +1 -1
  41. package/modules/agents/module.json +1 -1
  42. package/modules/agents/spec/module.yaml +1 -1
  43. package/modules/approvals/migrations/0005_approvals_grant_audit.down.sql +4 -0
  44. package/modules/approvals/migrations/0005_approvals_grant_audit.up.sql +20 -0
  45. package/modules/approvals/module.json +2 -2
  46. package/modules/approvals/package.json +1 -1
  47. package/modules/approvals/spec/module.yaml +12 -3
  48. package/modules/approvals/src/domain/capability.ts +12 -0
  49. package/modules/approvals/src/domain/grant.ts +69 -0
  50. package/modules/approvals/src/domain/types.ts +14 -0
  51. package/modules/approvals/src/index.ts +9 -0
  52. package/modules/approvals/src/platform.ts +8 -0
  53. package/modules/approvals/src/server/runtime.ts +4 -0
  54. package/modules/approvals/src/services/approvals-service.ts +80 -0
  55. package/modules/approvals/src/services/database-repository.ts +65 -6
  56. package/modules/approvals/src/services/migration.ts +34 -0
  57. package/modules/approvals/src/services/repository.ts +8 -0
  58. package/modules/audit/module.json +1 -1
  59. package/modules/audit/spec/module.yaml +1 -1
  60. package/modules/auth/module.json +2 -2
  61. package/modules/auth/package.json +1 -1
  62. package/modules/auth/spec/module.yaml +2 -2
  63. package/modules/auth/src/middleware/authentication.ts +5 -1
  64. package/modules/auth/src/services/auth-service.ts +26 -0
  65. package/modules/auth/src/services/database-repository.ts +9 -0
  66. package/modules/auth/src/services/repository.ts +2 -0
  67. package/modules/automations/module.json +1 -1
  68. package/modules/automations/spec/module.yaml +1 -1
  69. package/modules/connectors/migrations/0003_connectors_rotation_inventory.down.sql +2 -0
  70. package/modules/connectors/migrations/0003_connectors_rotation_inventory.up.sql +19 -0
  71. package/modules/connectors/module.json +8 -4
  72. package/modules/connectors/package.json +2 -1
  73. package/modules/connectors/spec/module.yaml +3 -2
  74. package/modules/connectors/src/cli/commands.json +17 -0
  75. package/modules/connectors/src/cli/index.ts +126 -0
  76. package/modules/connectors/src/services/credential-rotation.ts +221 -0
  77. package/modules/connectors/src/services/credential-vault.ts +6 -0
  78. package/modules/connectors/src/services/migration.ts +36 -0
  79. package/modules/directory/module.json +1 -1
  80. package/modules/directory/spec/module.yaml +1 -1
  81. package/modules/documents/migrations/0003_documents_rotation_inventory.down.sql +2 -0
  82. package/modules/documents/migrations/0003_documents_rotation_inventory.up.sql +18 -0
  83. package/modules/documents/module.json +8 -4
  84. package/modules/documents/package.json +2 -1
  85. package/modules/documents/spec/module.yaml +3 -2
  86. package/modules/documents/src/cli/commands.json +17 -0
  87. package/modules/documents/src/cli/index.ts +145 -0
  88. package/modules/documents/src/services/database-repository.ts +15 -4
  89. package/modules/documents/src/services/documents-service.ts +13 -9
  90. package/modules/documents/src/services/migration.ts +35 -0
  91. package/modules/documents/src/services/repository.ts +12 -2
  92. package/modules/documents/src/services/storage-rotation.ts +157 -0
  93. package/modules/exports/migrations/0003_exports_rotation_inventory.down.sql +1 -0
  94. package/modules/exports/migrations/0003_exports_rotation_inventory.up.sql +9 -0
  95. package/modules/exports/module.json +8 -4
  96. package/modules/exports/package.json +2 -1
  97. package/modules/exports/spec/module.yaml +3 -2
  98. package/modules/exports/src/cli/commands.json +17 -0
  99. package/modules/exports/src/cli/index.ts +145 -0
  100. package/modules/exports/src/server/index.ts +0 -1
  101. package/modules/exports/src/services/data-classes.ts +16 -13
  102. package/modules/exports/src/services/database-repository.ts +30 -32
  103. package/modules/exports/src/services/migration.ts +27 -0
  104. package/modules/exports/src/services/repository.ts +9 -10
  105. package/modules/exports/src/services/storage-rotation.ts +138 -0
  106. package/modules/import/module.json +1 -1
  107. package/modules/import/spec/module.yaml +1 -1
  108. package/modules/metering/module.json +1 -1
  109. package/modules/metering/spec/module.yaml +1 -1
  110. package/modules/notifications/module.json +1 -1
  111. package/modules/notifications/spec/module.yaml +1 -1
  112. package/modules/reports/module.json +1 -1
  113. package/modules/reports/spec/module.yaml +1 -1
  114. package/modules/sandbox/module.json +1 -1
  115. package/modules/sandbox/spec/module.yaml +1 -1
  116. package/modules/search/module.json +1 -1
  117. package/modules/search/spec/module.yaml +1 -1
  118. package/modules/system/migrations/0001_system_module_activations.down.sql +2 -0
  119. package/modules/system/migrations/0001_system_module_activations.up.sql +17 -0
  120. package/modules/system/module.json +3 -2
  121. package/modules/system/package.json +2 -2
  122. package/modules/system/spec/module.yaml +98 -1
  123. package/modules/system/src/client/ModulesView.tsrx +101 -8
  124. package/modules/system/src/client/api.ts +52 -11
  125. package/modules/system/src/client/module-columns.tsrx +32 -6
  126. package/modules/system/src/client/state.ts +26 -0
  127. package/modules/system/src/domain/modules.ts +93 -0
  128. package/modules/system/src/index.ts +13 -0
  129. package/modules/system/src/platform.ts +41 -1
  130. package/modules/system/src/server/capability.ts +6 -0
  131. package/modules/system/src/server/endpoints.ts +125 -5
  132. package/modules/system/src/server/index.ts +26 -0
  133. package/modules/system/src/server/module-catalog.ts +19 -0
  134. package/modules/system/src/server/runtime.ts +102 -0
  135. package/modules/system/src/services/database-repository.ts +91 -0
  136. package/modules/system/src/services/migration.ts +39 -0
  137. package/modules/system/src/services/module-activation-service.ts +245 -0
  138. package/modules/system/src/services/repository.ts +17 -0
  139. package/modules/system/translations/en.json +17 -1
  140. package/modules/system/translations/pl.json +17 -1
  141. package/modules/users/module.json +2 -2
  142. package/modules/users/package.json +1 -1
  143. package/modules/users/spec/module.yaml +6 -9
  144. package/modules/users/src/client/UsersView.tsrx +27 -13
  145. package/modules/users/src/client/member-columns.tsrx +13 -4
  146. package/modules/users/src/client/state.ts +16 -0
  147. package/modules/users/src/services/users-service.ts +3 -0
  148. package/modules/users/translations/en.json +2 -1
  149. package/modules/users/translations/pl.json +2 -1
  150. package/modules/workflows/module.json +2 -2
  151. package/modules/workflows/package.json +1 -1
  152. package/modules/workflows/spec/module.yaml +2 -2
  153. package/modules/workflows/src/services/database-repository.ts +93 -53
  154. package/package.json +1 -1
  155. package/packages/client/package.json +1 -0
  156. package/packages/client/src/ApplicationShell.tsrx +46 -5
  157. package/packages/client/src/index.ts +5 -0
  158. package/packages/client/src/shell/modules.ts +49 -0
  159. package/packages/client/src/shell/types.ts +3 -0
  160. package/packages/contracts/src/index.ts +11 -1
  161. package/packages/database/src/backup.ts +1 -0
  162. package/packages/database/src/migrations.ts +7 -0
  163. package/packages/harness/src/runtime.ts +169 -10
  164. package/packages/harness/src/tool-adapters.ts +6 -13
  165. package/packages/kernel/src/approval-grant.ts +310 -0
  166. package/packages/kernel/src/index.ts +20 -0
  167. package/packages/server/src/endpoint.ts +17 -1
  168. package/packages/server/src/index.ts +10 -0
  169. package/packages/server/src/module-activation.ts +69 -0
  170. package/packages/storage/src/envelope.ts +70 -21
  171. package/packages/storage/src/index.ts +7 -1
  172. package/packages/storage/src/port.ts +12 -1
  173. package/packages/storage/src/reseal.ts +128 -0
  174. package/assets/flowdular-banner.png +0 -0
@@ -0,0 +1,69 @@
1
+ import { APPROVAL_GRANT_INPUT_DIGEST_LENGTH } from '@flowdular/sdk/kernel';
2
+ import { APPROVAL_LIMITS } from './capability.ts';
3
+
4
+ /**
5
+ * How long after the approving decision the grant an approved request yields
6
+ * still verifies. The token is derived from the row and the key on every read,
7
+ * so the window is measured from `resolvedAt`, not from the read.
8
+ */
9
+ export const APPROVAL_GRANT_TTL_MS = 60 * 60 * 1_000;
10
+
11
+ const SUBJECT_PREFIX = 'capability:';
12
+ const CAPABILITY_ID = /^[a-z][a-z0-9-]*(\.[a-z][a-z0-9-]*)+$/;
13
+ const INPUT_DIGEST = new RegExp(
14
+ `^[a-f0-9]{${APPROVAL_GRANT_INPUT_DIGEST_LENGTH}}$`,
15
+ );
16
+ /* Prefix, id, one separator and the digest have to fit the subjectRef bound. */
17
+ export const APPROVAL_GRANT_CAPABILITY_ID_LENGTH =
18
+ APPROVAL_LIMITS.subjectRef -
19
+ SUBJECT_PREFIX.length -
20
+ 1 -
21
+ APPROVAL_GRANT_INPUT_DIGEST_LENGTH;
22
+
23
+ /** The capability and the exact input an approval is asked for. */
24
+ export interface CapabilitySubject {
25
+ readonly capabilityId: string;
26
+ /** `approvalInputDigest` of the invocation input, as the runner computes it. */
27
+ readonly inputDigest: string;
28
+ }
29
+
30
+ /**
31
+ * A `subjectRef` of the form `capability:<capabilityId>:<inputDigest>`. A
32
+ * request opened with it yields, once approved, a signed grant for exactly
33
+ * that capability and that input.
34
+ */
35
+ export function encodeCapabilitySubjectRef(subject: CapabilitySubject): string {
36
+ if (
37
+ !CAPABILITY_ID.test(subject.capabilityId) ||
38
+ subject.capabilityId.length > APPROVAL_GRANT_CAPABILITY_ID_LENGTH
39
+ ) {
40
+ throw new RangeError(
41
+ `capabilityId must be a lowercase dot-separated identifier of at most ${APPROVAL_GRANT_CAPABILITY_ID_LENGTH} characters.`,
42
+ );
43
+ }
44
+ if (!INPUT_DIGEST.test(subject.inputDigest)) {
45
+ throw new RangeError(
46
+ `inputDigest must be ${APPROVAL_GRANT_INPUT_DIGEST_LENGTH} lowercase hex characters.`,
47
+ );
48
+ }
49
+ return `${SUBJECT_PREFIX}${subject.capabilityId}:${subject.inputDigest}`;
50
+ }
51
+
52
+ /** Null for a subject reference that names a record rather than a capability. */
53
+ export function decodeCapabilitySubjectRef(
54
+ subjectRef: string,
55
+ ): CapabilitySubject | null {
56
+ if (!subjectRef.startsWith(SUBJECT_PREFIX)) return null;
57
+ const separator = subjectRef.lastIndexOf(':');
58
+ const capabilityId = subjectRef.slice(SUBJECT_PREFIX.length, separator);
59
+ const inputDigest = subjectRef.slice(separator + 1);
60
+ if (
61
+ separator <= SUBJECT_PREFIX.length ||
62
+ !CAPABILITY_ID.test(capabilityId) ||
63
+ capabilityId.length > APPROVAL_GRANT_CAPABILITY_ID_LENGTH ||
64
+ !INPUT_DIGEST.test(inputDigest)
65
+ ) {
66
+ return null;
67
+ }
68
+ return { capabilityId, inputDigest };
69
+ }
@@ -76,6 +76,20 @@ export interface ApprovalDecision {
76
76
  readonly decidedAt: number;
77
77
  }
78
78
 
79
+ /** What the ledger records about a request beyond its decisions. */
80
+ export const APPROVAL_AUDIT_ACTIONS = ['grant.issued'] as const;
81
+
82
+ export type ApprovalAuditAction = (typeof APPROVAL_AUDIT_ACTIONS)[number];
83
+
84
+ export interface ApprovalAuditEntry {
85
+ readonly id: string;
86
+ readonly tenantId: string;
87
+ readonly requestId: string;
88
+ readonly action: ApprovalAuditAction;
89
+ readonly metadata: Readonly<Record<string, string | number>>;
90
+ readonly occurredAt: number;
91
+ }
92
+
79
93
  /** What an erasure writes over the comment of a decision it keeps. */
80
94
  export const ERASED_DECISION_COMMENT = '[erased]';
81
95
 
@@ -30,6 +30,13 @@ export type {
30
30
  ApprovalsRequests,
31
31
  OpenApprovalInput,
32
32
  } from './domain/capability.ts';
33
+ export {
34
+ APPROVAL_GRANT_CAPABILITY_ID_LENGTH,
35
+ APPROVAL_GRANT_TTL_MS,
36
+ decodeCapabilitySubjectRef,
37
+ encodeCapabilitySubjectRef,
38
+ } from './domain/grant.ts';
39
+ export type { CapabilitySubject } from './domain/grant.ts';
33
40
 
34
41
  export {
35
42
  APPROVAL_DECISIONS,
@@ -38,6 +45,8 @@ export {
38
45
  isTerminalApprovalStatus,
39
46
  } from './domain/types.ts';
40
47
  export type {
48
+ ApprovalAuditAction,
49
+ ApprovalAuditEntry,
41
50
  ApprovalDecision,
42
51
  ApprovalDecisionKind,
43
52
  ApprovalMember,
@@ -1,3 +1,4 @@
1
+ import { approvalGrantKeyringFromEnvironment } from '@flowdular/sdk/kernel';
1
2
  import type {
2
3
  PlatformServerComposition,
3
4
  PlatformServerContext,
@@ -96,6 +97,9 @@ export function createServerComposition(
96
97
  context.capabilities.get<NotificationPublisher>(
97
98
  NOTIFICATIONS_PUBLISH_CAPABILITY,
98
99
  ),
100
+ /* Without FD_APPROVAL_GRANT_KEY an approved capability request yields no
101
+ grant, and the runner and the harness keep refusing the capability. */
102
+ grants: approvalGrantKeyringFromEnvironment(context.environment),
99
103
  });
100
104
  /* The runtime opens its database leases lazily, so the capability is a
101
105
  forwarder rather than a resolved object: registration must not force a
@@ -106,6 +110,10 @@ export function createServerComposition(
106
110
  open: async (input) => (await runtime.service()).capability().open(input),
107
111
  get: async (tenantId, id) =>
108
112
  (await runtime.service()).capability().get(tenantId, id),
113
+ grant: async (tenantId, id, subjectModule) =>
114
+ (await runtime.service())
115
+ .capability()
116
+ .grant(tenantId, id, subjectModule),
109
117
  list: async (tenantId, filter) =>
110
118
  (await runtime.service()).capability().list(tenantId, filter),
111
119
  cancel: async (tenantId, id, actorAccountId) =>
@@ -7,6 +7,7 @@ import {
7
7
  DATABASE_CAPABILITY_IDS,
8
8
  DATABASE_DIALECT_IDS,
9
9
  } from '@flowdular/sdk/database';
10
+ import type { ApprovalGrantKeyring } from '@flowdular/sdk/kernel';
10
11
  import type { JobRunner } from '@flowdular/sdk/server';
11
12
  import type { ApprovalMember } from '../domain/types.ts';
12
13
  import {
@@ -42,6 +43,8 @@ export interface ApprovalsRuntimeOptions {
42
43
  readonly notifications?: NotificationPublisherResolver;
43
44
  readonly callbacks?: ApprovalCallbackRegistry;
44
45
  readonly repository?: ApprovalsRepository;
46
+ /** Signs the grant an approved capability request yields; absent means none. */
47
+ readonly grants?: ApprovalGrantKeyring | undefined;
45
48
  readonly now?: () => number;
46
49
  }
47
50
 
@@ -122,6 +125,7 @@ export function createApprovalsRuntime(
122
125
  ...(options.notifications
123
126
  ? { notifications: options.notifications }
124
127
  : {}),
128
+ ...(options.grants ? { grants: options.grants } : {}),
125
129
  ...(options.now ? { now: options.now } : {}),
126
130
  }));
127
131
 
@@ -1,12 +1,21 @@
1
1
  import { randomUUID } from 'node:crypto';
2
+ import {
3
+ issueApprovalGrant,
4
+ type ApprovalGrantKeyring,
5
+ } from '@flowdular/sdk/kernel';
2
6
  import {
3
7
  APPROVAL_LIMITS,
4
8
  type ApprovalRequestFilter,
5
9
  type ApprovalsRequests,
6
10
  type OpenApprovalInput,
7
11
  } from '../domain/capability.ts';
12
+ import {
13
+ APPROVAL_GRANT_TTL_MS,
14
+ decodeCapabilitySubjectRef,
15
+ } from '../domain/grant.ts';
8
16
  import {
9
17
  APPROVAL_STATUSES,
18
+ type ApprovalAuditEntry,
10
19
  type ApprovalDecideOutcome,
11
20
  type ApprovalDecision,
12
21
  type ApprovalListPage,
@@ -61,6 +70,8 @@ export interface ApprovalsServiceOptions {
61
70
  readonly defaultExpiryDays: (tenantId: string) => number | Promise<number>;
62
71
  readonly notifications?: NotificationPublisherResolver;
63
72
  readonly callbacks?: ApprovalCallbackRegistry;
73
+ /** Signs the grant an approved capability request yields; absent means none is issued. */
74
+ readonly grants?: ApprovalGrantKeyring | undefined;
64
75
  readonly now?: () => number;
65
76
  }
66
77
 
@@ -71,6 +82,7 @@ export class ApprovalsService {
71
82
  readonly #defaultExpiryDays: ApprovalsServiceOptions['defaultExpiryDays'];
72
83
  readonly #notifications: NotificationPublisherResolver | undefined;
73
84
  readonly #callbacks: ApprovalCallbackRegistry;
85
+ readonly #grants: ApprovalGrantKeyring | undefined;
74
86
  readonly #now: () => number;
75
87
 
76
88
  constructor(options: ApprovalsServiceOptions) {
@@ -80,6 +92,7 @@ export class ApprovalsService {
80
92
  this.#defaultExpiryDays = options.defaultExpiryDays;
81
93
  this.#notifications = options.notifications;
82
94
  this.#callbacks = options.callbacks ?? createApprovalCallbackRegistry();
95
+ this.#grants = options.grants;
83
96
  this.#now = options.now ?? Date.now;
84
97
  }
85
98
 
@@ -186,6 +199,48 @@ export class ApprovalsService {
186
199
  );
187
200
  }
188
201
 
202
+ /**
203
+ * The grant an approved capability request yields, derived from the row and
204
+ * the current key on every read: the same claims sign to the same token
205
+ * under the same key, and a rotated key signs a fresh one that verifies the
206
+ * same. Nothing is issued for any other state, or once the window closed.
207
+ */
208
+ grantFor(request: ApprovalRequest): string | undefined {
209
+ if (!this.#grants || request.status !== 'approved') return undefined;
210
+ if (request.resolvedAt === null) return undefined;
211
+ const subject = decodeCapabilitySubjectRef(request.subjectRef);
212
+ if (!subject) return undefined;
213
+ const expiresAt = request.resolvedAt + APPROVAL_GRANT_TTL_MS;
214
+ if (expiresAt <= this.#now()) return undefined;
215
+ return issueApprovalGrant(this.#grants, {
216
+ tenantId: request.tenantId,
217
+ capabilityId: subject.capabilityId,
218
+ inputDigest: subject.inputDigest,
219
+ requestId: request.id,
220
+ issuedAt: request.resolvedAt,
221
+ expiresAt,
222
+ nonce: request.id,
223
+ }).token;
224
+ }
225
+
226
+ /* The token reaches only the module that asked the question: a holder of
227
+ the capability naming another module's request is answered null. */
228
+ async grant(
229
+ tenantId: string,
230
+ id: string,
231
+ subjectModule: string,
232
+ ): Promise<string | null> {
233
+ const module = bounded(
234
+ subjectModule,
235
+ 'subjectModule',
236
+ 1,
237
+ APPROVAL_LIMITS.subjectModule,
238
+ );
239
+ const request = await this.get(tenantId, id);
240
+ if (!request || request.subjectModule !== module) return null;
241
+ return this.grantFor(request) ?? null;
242
+ }
243
+
189
244
  async detail(
190
245
  tenantId: string,
191
246
  id: string,
@@ -458,6 +513,8 @@ export class ApprovalsService {
458
513
  return {
459
514
  open: (input) => this.open(input),
460
515
  get: (tenantId, id) => this.get(tenantId, id),
516
+ grant: (tenantId, id, subjectModule) =>
517
+ this.grant(tenantId, id, subjectModule),
461
518
  list: (tenantId, filter) => this.list(tenantId, filter),
462
519
  cancel: async (tenantId, id, actorAccountId) =>
463
520
  (await this.cancel(tenantId, id, actorAccountId)).request,
@@ -476,6 +533,28 @@ export class ApprovalsService {
476
533
  return request;
477
534
  }
478
535
 
536
+ /* The moment a capability request is approved is the moment its grant can
537
+ be read, so that is what the ledger records, under the deciding
538
+ transaction, with what the grant will say and which key signs it. */
539
+ #grantIssued(resolved: ApprovalRequest): ApprovalAuditEntry | null {
540
+ if (!this.#grants || resolved.status !== 'approved') return null;
541
+ const subject = decodeCapabilitySubjectRef(resolved.subjectRef);
542
+ if (!subject || resolved.resolvedAt === null) return null;
543
+ return {
544
+ id: randomUUID(),
545
+ tenantId: resolved.tenantId,
546
+ requestId: resolved.id,
547
+ action: 'grant.issued',
548
+ metadata: {
549
+ capabilityId: subject.capabilityId,
550
+ inputDigest: subject.inputDigest,
551
+ keyId: this.#grants.keyId,
552
+ expiresAt: resolved.resolvedAt + APPROVAL_GRANT_TTL_MS,
553
+ },
554
+ occurredAt: resolved.resolvedAt,
555
+ };
556
+ }
557
+
479
558
  /* Only a member who may read the request is told its state; every caller
480
559
  settles readability before this runs. */
481
560
  #assertPending(request: ApprovalRequest): void {
@@ -512,6 +591,7 @@ export class ApprovalsService {
512
591
  },
513
592
  resolve,
514
593
  resolvedAt: decidedAt,
594
+ audit: (resolved) => this.#grantIssued(resolved),
515
595
  });
516
596
  if (result.outcome === 'not-found') {
517
597
  throw new ApprovalsServiceError(
@@ -8,6 +8,8 @@ import type {
8
8
  import { integer, runDatabaseMigrations } from '@flowdular/sdk/database';
9
9
  import { keysetWhere } from '@flowdular/sdk/server';
10
10
  import type {
11
+ ApprovalAuditAction,
12
+ ApprovalAuditEntry,
11
13
  ApprovalDecision,
12
14
  ApprovalDecisionKind,
13
15
  ApprovalListPage,
@@ -55,6 +57,15 @@ interface RequestRow {
55
57
  created_at: number | bigint | string;
56
58
  }
57
59
 
60
+ interface AuditRow {
61
+ id: string;
62
+ tenant_id: string;
63
+ request_id: string;
64
+ action: ApprovalAuditAction;
65
+ metadata_json: string;
66
+ occurred_at: number | bigint | string;
67
+ }
68
+
58
69
  interface DecisionRow {
59
70
  id: string;
60
71
  tenant_id: string;
@@ -125,6 +136,13 @@ const SQL = {
125
136
  resolveRequest: `UPDATE approvals_requests
126
137
  SET status = $1, resolved_at = $2
127
138
  WHERE tenant_id = $3 AND id = $4 AND status = 'pending'`,
139
+ insertAudit: `INSERT INTO approvals_audit
140
+ (id, tenant_id, request_id, action, metadata_json, occurred_at)
141
+ VALUES ($1, $2, $3, $4, $5, $6)`,
142
+ listAudit: `SELECT id, tenant_id, request_id, action, metadata_json, occurred_at
143
+ FROM approvals_audit
144
+ WHERE tenant_id = $1 AND request_id = $2
145
+ ORDER BY occurred_at, id`,
128
146
  /* The one cross-tenant read. It returns routing columns only; the request
129
147
  is read again under the tenant the routing row named before it expires. */
130
148
  dueExpiries: `SELECT tenant_id, id, expires_at, status FROM approvals_requests
@@ -156,6 +174,8 @@ const SQL = {
156
174
  WHERE tenant_id = $1 AND request_id = ANY($2::text[])`,
157
175
  deleteDecisionsOf: `DELETE FROM approvals_decisions
158
176
  WHERE tenant_id = $1 AND request_id = ANY($2::text[])`,
177
+ deleteAuditOf: `DELETE FROM approvals_audit
178
+ WHERE tenant_id = $1 AND request_id = ANY($2::text[])`,
159
179
  deleteRequests: `DELETE FROM approvals_requests
160
180
  WHERE tenant_id = $1 AND id = ANY($2::text[])`,
161
181
  /* PostgreSQL takes no LIMIT on an UPDATE, so the batch is chosen by the
@@ -266,6 +286,17 @@ function requestFrom(row: RequestRow): ApprovalRequest {
266
286
  };
267
287
  }
268
288
 
289
+ function auditFrom(row: AuditRow): ApprovalAuditEntry {
290
+ return {
291
+ id: row.id,
292
+ tenantId: row.tenant_id,
293
+ requestId: row.request_id,
294
+ action: row.action,
295
+ metadata: JSON.parse(row.metadata_json) as ApprovalAuditEntry['metadata'],
296
+ occurredAt: integer(row.occurred_at, 'occurredAt'),
297
+ };
298
+ }
299
+
269
300
  function decisionFrom(row: DecisionRow): ApprovalDecision {
270
301
  return {
271
302
  id: row.id,
@@ -530,13 +561,25 @@ export class DatabaseApprovalsRepository implements ApprovalsRepository {
530
561
  input.tenantId,
531
562
  input.requestId,
532
563
  ]);
564
+ const resolved = {
565
+ ...request,
566
+ status: terminal,
567
+ resolvedAt: input.resolvedAt,
568
+ };
569
+ const audit = input.audit?.(resolved);
570
+ if (audit) {
571
+ await this.#exec(transaction, SQL.insertAudit, [
572
+ audit.id,
573
+ input.tenantId,
574
+ input.requestId,
575
+ audit.action,
576
+ JSON.stringify(audit.metadata),
577
+ audit.occurredAt,
578
+ ]);
579
+ }
533
580
  return {
534
581
  outcome: 'recorded',
535
- request: {
536
- ...request,
537
- status: terminal,
538
- resolvedAt: input.resolvedAt,
539
- },
582
+ request: resolved,
540
583
  decisions,
541
584
  resolved: true,
542
585
  } as const;
@@ -545,6 +588,18 @@ export class DatabaseApprovalsRepository implements ApprovalsRepository {
545
588
  );
546
589
  }
547
590
 
591
+ async listAudit(
592
+ tenantId: string,
593
+ requestId: string,
594
+ ): Promise<readonly ApprovalAuditEntry[]> {
595
+ return (
596
+ await this.#read<AuditRow>(tenantId, {
597
+ text: SQL.listAudit,
598
+ parameters: [tenantId, requestId],
599
+ })
600
+ ).map(auditFrom);
601
+ }
602
+
548
603
  async listDueExpiries(
549
604
  now: number,
550
605
  limit: number,
@@ -655,7 +710,11 @@ export class DatabaseApprovalsRepository implements ApprovalsRepository {
655
710
  ): Promise<number> {
656
711
  if (rows.length === 0) return 0;
657
712
  const ids = rows.map((row) => row.id);
658
- for (const statement of [SQL.deleteEligibleOf, SQL.deleteDecisionsOf]) {
713
+ for (const statement of [
714
+ SQL.deleteEligibleOf,
715
+ SQL.deleteDecisionsOf,
716
+ SQL.deleteAuditOf,
717
+ ]) {
659
718
  await this.#exec(transaction, statement, [tenantId, ids]);
660
719
  }
661
720
  await this.#exec(transaction, SQL.deleteRequests, [tenantId, ids]);
@@ -139,6 +139,29 @@ CREATE INDEX IF NOT EXISTS approvals_requests_created_idx
139
139
  ON approvals_requests (tenant_id, created_at, id);
140
140
  `;
141
141
 
142
+ /* Mirrors migrations/0005_approvals_grant_audit.up.sql byte for byte. */
143
+ export const APPROVALS_MIGRATION_005_GRANT_AUDIT = `-- The grant an approved request yields is derived from the row and the key on
144
+ -- every read, so nothing about the token itself is stored. What is stored is
145
+ -- the fact of its issuance: one row per resolved request whose subject names
146
+ -- a capability, with the capability, the input digest, the key that signs and
147
+ -- the moment the grant stops verifying, written in the deciding transaction.
148
+ CREATE TABLE IF NOT EXISTS approvals_audit (
149
+ id TEXT PRIMARY KEY,
150
+ tenant_id TEXT NOT NULL,
151
+ request_id TEXT NOT NULL,
152
+ action TEXT NOT NULL,
153
+ metadata_json TEXT NOT NULL,
154
+ occurred_at BIGINT NOT NULL
155
+ );
156
+ CREATE INDEX IF NOT EXISTS approvals_audit_request_idx
157
+ ON approvals_audit (tenant_id, request_id, occurred_at, id);
158
+ ALTER TABLE approvals_audit ENABLE ROW LEVEL SECURITY;
159
+ ALTER TABLE approvals_audit FORCE ROW LEVEL SECURITY;
160
+ CREATE POLICY approvals_audit_tenant_policy ON approvals_audit
161
+ USING (tenant_id = current_setting('coreloom.tenant_id', true))
162
+ WITH CHECK (tenant_id = current_setting('coreloom.tenant_id', true));
163
+ `;
164
+
142
165
  export const databaseMigrations: readonly DatabaseMigration[] = [
143
166
  {
144
167
  id: '0001_approvals_core',
@@ -219,4 +242,15 @@ export const databaseMigrations: readonly DatabaseMigration[] = [
219
242
  () => database.schema.hasIndex('approvals_requests_created_idx'),
220
243
  ]),
221
244
  },
245
+ {
246
+ id: '0005_approvals_grant_audit',
247
+ sql: { postgresql: APPROVALS_MIGRATION_005_GRANT_AUDIT },
248
+ inspectExisting: (database) =>
249
+ postgresTenantTableState(
250
+ database,
251
+ 'approvals_audit',
252
+ 'approvals_audit_tenant_policy',
253
+ [() => database.schema.hasIndex('approvals_audit_request_idx')],
254
+ ),
255
+ },
222
256
  ];
@@ -1,4 +1,5 @@
1
1
  import type {
2
+ ApprovalAuditEntry,
2
3
  ApprovalDecision,
3
4
  ApprovalListPage,
4
5
  ApprovalRequest,
@@ -37,6 +38,8 @@ export interface DecideApprovalInput {
37
38
  decisions: readonly ApprovalDecision[],
38
39
  ) => TerminalApprovalStatus | null;
39
40
  readonly resolvedAt: number;
41
+ /** Written in the same transaction when the request resolves; null writes nothing. */
42
+ readonly audit?: (request: ApprovalRequest) => ApprovalAuditEntry | null;
40
43
  }
41
44
 
42
45
  export type DecideApprovalResult =
@@ -101,6 +104,11 @@ export interface ApprovalsRepository {
101
104
  /** Pending requests this account is in the eligibility snapshot of. */
102
105
  countDecidable(tenantId: string, accountId: string): Promise<number>;
103
106
  decide(input: DecideApprovalInput): Promise<DecideApprovalResult>;
107
+ /** The issuance ledger of one request, oldest first. */
108
+ listAudit(
109
+ tenantId: string,
110
+ requestId: string,
111
+ ): Promise<readonly ApprovalAuditEntry[]>;
104
112
  /** Cross-tenant, routing columns only, on the read-only background lease. */
105
113
  listDueExpiries(
106
114
  now: number,
@@ -14,7 +14,7 @@
14
14
  "dependencies": [
15
15
  {
16
16
  "id": "system.core",
17
- "range": "^0.7.0"
17
+ "range": "^0.8.0"
18
18
  },
19
19
  {
20
20
  "id": "auth.core",
@@ -13,7 +13,7 @@ capabilities:
13
13
  - cli
14
14
  dependencies:
15
15
  - id: system.core
16
- range: ^0.7.0
16
+ range: ^0.8.0
17
17
  - id: auth.core
18
18
  range: ^0.13.0
19
19
  provides:
@@ -3,7 +3,7 @@
3
3
  "schemaVersion": 1,
4
4
  "id": "auth.core",
5
5
  "package": "@flowdular/module-auth",
6
- "version": "0.13.10",
6
+ "version": "0.13.11",
7
7
  "profile": "full",
8
8
  "capabilities": ["api", "database", "client", "translations", "cli"],
9
9
  "platform": {
@@ -12,7 +12,7 @@
12
12
  "dependencies": [
13
13
  {
14
14
  "id": "system.core",
15
- "range": "^0.7.0"
15
+ "range": "^0.8.0"
16
16
  }
17
17
  ],
18
18
  "tenancy": "required",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flowdular/module-auth",
3
- "version": "0.13.10",
3
+ "version": "0.13.11",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": "./src/index.ts",
@@ -1,6 +1,6 @@
1
1
  schemaVersion: 2
2
2
  id: auth.core
3
- specVersion: 0.13.10
3
+ specVersion: 0.13.11
4
4
  status: approved
5
5
  name: Authentication Core
6
6
  description: Provides tenant-aware accounts, password authentication, sessions, scopes, tenant roles, API tokens for machine clients, the module settings store, the auth audit trail, security headers, authentication screens, and server authorization middleware.
@@ -13,7 +13,7 @@ capabilities:
13
13
  - cli
14
14
  dependencies:
15
15
  - id: system.core
16
- range: ^0.7.0
16
+ range: ^0.8.0
17
17
  tenancy: required
18
18
  locales:
19
19
  - en
@@ -70,7 +70,11 @@ export function endpointIdentityFromContext(
70
70
  ): EndpointIdentity | null {
71
71
  const principal = principalFromContext(context);
72
72
  return principal
73
- ? { subjectId: principal.accountId, permissions: new Set(principal.scopes) }
73
+ ? {
74
+ subjectId: principal.accountId,
75
+ tenantId: principal.tenantId,
76
+ permissions: new Set(principal.scopes),
77
+ }
74
78
  : null;
75
79
  }
76
80
 
@@ -385,6 +385,8 @@ export const AUDIT_ACTIONS = Object.freeze({
385
385
  roleDeleted: 'auth.role.deleted',
386
386
  settingsUpdated: 'settings.updated',
387
387
  settingsFlagChanged: 'settings.flag.changed',
388
+ moduleActivated: 'system.module.activated',
389
+ moduleDeactivated: 'system.module.deactivated',
388
390
  });
389
391
 
390
392
  export const AUDIT_ACTION_LIST = Object.freeze(Object.values(AUDIT_ACTIONS));
@@ -886,6 +888,13 @@ export class AuthService {
886
888
  );
887
889
  }
888
890
 
891
+ /** How many members hold the owner role, counted the same way for the same screen. */
892
+ countTenantOwners(tenantId: string): Promise<number> {
893
+ return this.#repository.countTenantOwners(
894
+ this.#identifier(tenantId, 'tenantId'),
895
+ );
896
+ }
897
+
889
898
  /**
890
899
  * The identity bindings this workspace's own providers assert about its
891
900
  * members, one bounded page per call. A binding names the account, the
@@ -2912,6 +2921,23 @@ export class AuthService {
2912
2921
  );
2913
2922
  }
2914
2923
 
2924
+ /** The one audit row a committed per-workspace module activation change owes. */
2925
+ async recordModuleActivation(
2926
+ actor: AuthActor,
2927
+ change: { readonly moduleId: string; readonly active: boolean },
2928
+ ): Promise<void> {
2929
+ await this.#audit(
2930
+ actor.tenantId,
2931
+ this.#actorOf(actor),
2932
+ change.active
2933
+ ? AUDIT_ACTIONS.moduleActivated
2934
+ : AUDIT_ACTIONS.moduleDeactivated,
2935
+ 'module',
2936
+ change.moduleId,
2937
+ { active: change.active },
2938
+ );
2939
+ }
2940
+
2915
2941
  async queryAudit(query: AuditQuery): Promise<AuditActorPage> {
2916
2942
  const limit = Math.min(
2917
2943
  Math.max(1, Math.trunc(query.limit)),
@@ -939,6 +939,15 @@ export class DatabaseAuthRepository implements AuthRepository {
939
939
  return rows[0] ? integer(rows[0].total, 'total') : 0;
940
940
  }
941
941
 
942
+ async countTenantOwners(tenantId: string): Promise<number> {
943
+ const rows = await this.#query<CountRow>(tenantId, {
944
+ text: `SELECT count(*) AS total FROM auth_memberships
945
+ WHERE tenant_id = $1 AND role = 'owner'`,
946
+ parameters: [tenantId],
947
+ });
948
+ return rows[0] ? integer(rows[0].total, 'total') : 0;
949
+ }
950
+
942
951
  async findTenantMember(
943
952
  tenantId: string,
944
953
  accountId: string,
@@ -363,6 +363,8 @@ export interface AuthRepository {
363
363
  ): Promise<TenantMemberSortedPage>;
364
364
  /** How many memberships the workspace holds, an index range over 0030. */
365
365
  countTenantMembers(tenantId: string): Promise<number>;
366
+ /** How many of those memberships hold the owner role, whatever their status. */
367
+ countTenantOwners(tenantId: string): Promise<number>;
366
368
  /** One member, for a caller that needs a single account rather than the roll. */
367
369
  findTenantMember(
368
370
  tenantId: string,
@@ -13,7 +13,7 @@
13
13
  "dependencies": [
14
14
  {
15
15
  "id": "system.core",
16
- "range": "^0.7.0"
16
+ "range": "^0.8.0"
17
17
  },
18
18
  {
19
19
  "id": "auth.core",
@@ -13,7 +13,7 @@ capabilities:
13
13
  - cli
14
14
  dependencies:
15
15
  - id: system.core
16
- range: ^0.7.0
16
+ range: ^0.8.0
17
17
  - id: auth.core
18
18
  range: ^0.13.0
19
19
  - id: agents.core
@@ -0,0 +1,2 @@
1
+ REVOKE SELECT (tenant_id, credential_key_id) ON connectors_instances FROM coreloom_background;
2
+ DROP POLICY IF EXISTS connectors_instances_background_policy ON connectors_instances;