@cosmicdrift/kumiko-bundled-features 0.365.0 → 0.366.0
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/dist/admin-shell/feature.js +9 -9
- package/dist/agent-tools/tool-dispatch.d.ts +9 -1
- package/dist/agent-tools/tool-dispatch.js +55 -5
- package/dist/audit/feature.js +1 -1
- package/dist/auth-email-password/constants.d.ts +0 -1
- package/dist/auth-email-password/constants.js +0 -2
- package/dist/auth-email-password/feature.js +3 -3
- package/dist/auth-email-password/handlers/invite-create.write.js +1 -0
- package/dist/auth-mfa/feature.js +4 -4
- package/dist/auth-mfa-user-data/index.js +1 -1
- package/dist/billing-foundation/consumer-protection/register-consent.js +1 -1
- package/dist/billing-foundation/feature.js +1 -1
- package/dist/cap-counter/cap-reservation.js +73 -25
- package/dist/cap-counter/constants.d.ts +1 -0
- package/dist/cap-counter/constants.js +2 -0
- package/dist/cap-counter/feature.js +2 -2
- package/dist/cap-counter/tables.js +5 -2
- package/dist/cap-overview/feature.js +3 -3
- package/dist/channel-discord/feature.js +2 -2
- package/dist/channel-email/feature.js +1 -1
- package/dist/channel-in-app/feature.js +1 -1
- package/dist/channel-push/feature.js +1 -1
- package/dist/channel-slack/feature.js +2 -2
- package/dist/channel-teams/feature.js +2 -2
- package/dist/channel-telegram/feature.js +2 -2
- package/dist/compliance-profiles/feature.js +1 -1
- package/dist/compliance-profiles-ops/index.js +3 -3
- package/dist/config/feature.js +1 -1
- package/dist/custom-fields/feature.js +2 -2
- package/dist/custom-fields/wire-for-entity.js +17 -13
- package/dist/data-retention/feature.js +1 -1
- package/dist/data-retention/presets.d.ts +4 -4
- package/dist/delivery/db/queries/address-opt-outs.d.ts +1 -1
- package/dist/delivery/db/queries/address-opt-outs.js +6 -2
- package/dist/delivery/db/queries/preferences.d.ts +1 -1
- package/dist/delivery/db/queries/preferences.js +4 -4
- package/dist/delivery/delivery-service.js +9 -2
- package/dist/delivery/feature.js +9 -5
- package/dist/delivery/handlers/resubscribe-address.write.js +13 -9
- package/dist/delivery/handlers/resubscribe-user.write.js +3 -1
- package/dist/delivery/handlers/set-preference.write.js +3 -1
- package/dist/delivery/handlers/unsubscribe-address.write.js +3 -1
- package/dist/delivery/handlers/unsubscribe-user.write.js +3 -1
- package/dist/delivery/notification-type-aliases.d.ts +3 -0
- package/dist/delivery/notification-type-aliases.js +11 -0
- package/dist/derivatives-sharp/feature.js +1 -1
- package/dist/document-ingest-foundation/feature.js +2 -2
- package/dist/file-derivatives/feature.js +3 -3
- package/dist/file-foundation/feature.js +1 -1
- package/dist/file-provider-inmemory/feature.js +2 -2
- package/dist/file-provider-s3/feature.js +3 -3
- package/dist/file-provider-s3-env/feature.js +2 -2
- package/dist/files-tenant-data/index.js +1 -1
- package/dist/folders/feature.js +2 -2
- package/dist/folders-user-data/index.js +1 -1
- package/dist/form-draft/feature.js +2 -2
- package/dist/form-draft-user-data/index.js +1 -1
- package/dist/inbound-mail-foundation/connect-routes.d.ts +3 -0
- package/dist/inbound-mail-foundation/connect-routes.js +6 -1
- package/dist/inbound-mail-foundation/feature.js +1 -1
- package/dist/inbound-mail-foundation/types.d.ts +17 -7
- package/dist/inbound-mail-foundation/watch-supervisor.js +16 -2
- package/dist/inbound-provider-imap/feature.js +2 -2
- package/dist/inbound-provider-inmemory/feature.js +1 -1
- package/dist/ledger/feature.js +5 -5
- package/dist/legal-pages/feature.js +1 -1
- package/dist/mail-foundation/feature.js +1 -1
- package/dist/mail-transport-inmemory/feature.js +2 -2
- package/dist/mail-transport-smtp/feature.js +3 -3
- package/dist/managed-pages/css-gate.js +1 -1
- package/dist/managed-pages/feature.js +2 -2
- package/dist/metrics/feature.js +2 -2
- package/dist/notes-history/feature.js +2 -2
- package/dist/notes-history-user-data/index.js +1 -1
- package/dist/personal-access-tokens/feature.js +17 -13
- package/dist/readiness/feature.js +2 -2
- package/dist/renderer-foundation/feature.js +1 -1
- package/dist/renderer-pdf/feature.js +1 -1
- package/dist/secrets/feature.js +1 -1
- package/dist/seo/feature.js +1 -1
- package/dist/sessions/feature.js +20 -16
- package/dist/step-dispatcher/constants.d.ts +10 -0
- package/dist/step-dispatcher/constants.js +17 -0
- package/dist/step-dispatcher/deliver-dispatch-request.d.ts +14 -0
- package/dist/step-dispatcher/deliver-dispatch-request.js +163 -0
- package/dist/step-dispatcher/feature.js +48 -101
- package/dist/step-dispatcher/webhook-runner.d.ts +7 -4
- package/dist/step-dispatcher/webhook-runner.js +26 -11
- package/dist/subscription-mollie/feature.js +1 -1
- package/dist/subscription-stripe/feature.js +3 -3
- package/dist/tags/feature.js +2 -2
- package/dist/template-resolver/feature.js +2 -2
- package/dist/template-resolver-user-data/index.js +1 -1
- package/dist/tenant/feature.js +4 -4
- package/dist/tenant/seeding.js +1 -1
- package/dist/tenant-lifecycle/feature.js +2 -3
- package/dist/tenant-lifecycle/stages.js +4 -1
- package/dist/tenant-settings/feature.js +1 -1
- package/dist/tier-engine/feature.js +86 -72
- package/dist/user/feature.js +1 -1
- package/dist/user-data-rights/feature.js +4 -4
- package/dist/user-data-rights-defaults/feature.js +2 -2
- package/dist/user-profile/feature.js +4 -4
- package/dist/waitlist/feature.js +2 -2
- package/package.json +10 -10
- package/src/agent-tools/changes.json +6 -0
- package/src/auth-email-password/changes.json +13 -0
- package/src/cap-counter/changes.json +7 -0
- package/src/crypto-shredding/changes.json +13 -0
- package/src/delivery/changes.json +6 -0
- package/src/inbound-mail-foundation/changes.json +13 -0
- package/src/step-dispatcher/changes.json +8 -1
|
@@ -21,18 +21,18 @@ export function createAdminShellFeature(options = {}) {
|
|
|
21
21
|
category: "operations",
|
|
22
22
|
recommended: false,
|
|
23
23
|
});
|
|
24
|
-
r.requires("user");
|
|
25
|
-
r.requires("tenant");
|
|
26
|
-
r.requires("audit");
|
|
27
|
-
r.requires("jobs");
|
|
28
|
-
r.requires("metrics");
|
|
29
|
-
r.requires("metrics-system");
|
|
24
|
+
r.requires({ features: ["user"] });
|
|
25
|
+
r.requires({ features: ["tenant"] });
|
|
26
|
+
r.requires({ features: ["audit"] });
|
|
27
|
+
r.requires({ features: ["jobs"] });
|
|
28
|
+
r.requires({ features: ["metrics"] });
|
|
29
|
+
r.requires({ features: ["metrics-system"] });
|
|
30
30
|
if (includeTierAdmin)
|
|
31
|
-
r.requires("tier-engine");
|
|
31
|
+
r.requires({ features: ["tier-engine"] });
|
|
32
32
|
if (includeCapOverview)
|
|
33
|
-
r.requires("cap-overview");
|
|
33
|
+
r.requires({ features: ["cap-overview"] });
|
|
34
34
|
if (includeDocumentDesigner)
|
|
35
|
-
r.requires("document-designer");
|
|
35
|
+
r.requires({ features: ["document-designer"] });
|
|
36
36
|
r.translations({ keys: ADMIN_SHELL_I18N });
|
|
37
37
|
const tenantNav = [
|
|
38
38
|
"admin-shell:nav:tenant-overview",
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { SessionUser, WriteResult } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
import { type Logger } from "@cosmicdrift/kumiko-framework/logging";
|
|
2
3
|
import type { ToolDispatchDescriptor } from "./types.js";
|
|
3
4
|
/** Narrow view of `Dispatcher` (packages/framework/src/pipeline/dispatcher.ts) — kept out of
|
|
4
5
|
* `ToolDispatchDescriptor`'s reach and small enough for tests to build a plain object instead
|
|
@@ -17,6 +18,9 @@ export type ToolCallResult = {
|
|
|
17
18
|
} | {
|
|
18
19
|
readonly ok: false;
|
|
19
20
|
readonly error: string;
|
|
21
|
+
readonly code?: string;
|
|
22
|
+
readonly retryable?: boolean;
|
|
23
|
+
readonly retryAfterSeconds?: number;
|
|
20
24
|
};
|
|
21
25
|
export type ToolCallRequest = {
|
|
22
26
|
readonly dispatcher: ToolDispatcher;
|
|
@@ -30,10 +34,14 @@ export type ToolCallRequest = {
|
|
|
30
34
|
/** Idempotency scope: `${runId}:${toolCallId}` becomes the write requestId. */
|
|
31
35
|
readonly runId: string;
|
|
32
36
|
readonly toolCallId: string;
|
|
37
|
+
/** Receives the cause of unexpected (5xx) failures, which the tool result never carries. */
|
|
38
|
+
readonly log?: Pick<Logger, "error">;
|
|
33
39
|
};
|
|
34
40
|
/** Executes one LLM-issued tool call. Never throws — a missing tool, a missing argument, a
|
|
35
41
|
* disallowed filter field, or a rejected handler call (cap check, validation, ...) all come
|
|
36
42
|
* back as `{ ok: false }` so the agent loop can feed the error back to the model instead of
|
|
37
|
-
* crashing.
|
|
43
|
+
* crashing. Client errors (< 500) keep their message and carry a `code`; 429 adds
|
|
44
|
+
* `retryable`. Server-side failures (>= 500, unknown throws) are logged and returned as a
|
|
45
|
+
* generic message so internals never reach the model. `request.user` must be the caller's real identity — every server-kind tool runs
|
|
38
46
|
* through the same permission-checked dispatcher pipeline as a normal HTTP request. */
|
|
39
47
|
export declare function dispatchToolCall(request: ToolCallRequest): Promise<ToolCallResult>;
|
|
@@ -1,6 +1,48 @@
|
|
|
1
|
+
import { isKumikoError } from "@cosmicdrift/kumiko-framework/errors";
|
|
1
2
|
import { runAsDirectCallEntry } from "@cosmicdrift/kumiko-framework/internal/request-context";
|
|
3
|
+
import { createFallbackLogger } from "@cosmicdrift/kumiko-framework/logging";
|
|
2
4
|
import { isOneOf } from "@cosmicdrift/kumiko-types/type-guards";
|
|
3
5
|
import { FILTER_OPS, isRecord } from "./filter-ops.js";
|
|
6
|
+
const HTTP_TOO_MANY_REQUESTS = 429;
|
|
7
|
+
const HTTP_SERVER_ERROR = 500;
|
|
8
|
+
const UNEXPECTED_FAILURE_MESSAGE = "Tool failed unexpectedly";
|
|
9
|
+
const fallbackLog = createFallbackLogger("agent-tools");
|
|
10
|
+
function toolFailureOf(err) {
|
|
11
|
+
if (isKumikoError(err)) {
|
|
12
|
+
return {
|
|
13
|
+
code: err.code,
|
|
14
|
+
httpStatus: err.httpStatus,
|
|
15
|
+
message: err.message,
|
|
16
|
+
retryAfterSeconds: err.retryAfterSeconds,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
return {
|
|
20
|
+
code: "internal_error",
|
|
21
|
+
httpStatus: HTTP_SERVER_ERROR,
|
|
22
|
+
message: err instanceof Error ? err.message : String(err),
|
|
23
|
+
retryAfterSeconds: undefined,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
function createFailureClassifier(toolName, log) {
|
|
27
|
+
return (failure, cause) => {
|
|
28
|
+
if (failure.httpStatus === HTTP_TOO_MANY_REQUESTS) {
|
|
29
|
+
return {
|
|
30
|
+
ok: false,
|
|
31
|
+
error: failure.message,
|
|
32
|
+
code: failure.code,
|
|
33
|
+
retryable: true,
|
|
34
|
+
...(failure.retryAfterSeconds !== undefined && {
|
|
35
|
+
retryAfterSeconds: failure.retryAfterSeconds,
|
|
36
|
+
}),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
if (failure.httpStatus < HTTP_SERVER_ERROR) {
|
|
40
|
+
return { ok: false, error: failure.message, code: failure.code };
|
|
41
|
+
}
|
|
42
|
+
log.error("agent tool call failed unexpectedly", { toolName, code: failure.code, cause });
|
|
43
|
+
return { ok: false, error: UNEXPECTED_FAILURE_MESSAGE, code: failure.code };
|
|
44
|
+
};
|
|
45
|
+
}
|
|
4
46
|
const SEARCH_RESULT_LIMIT = 10;
|
|
5
47
|
const DEFAULT_LIST_LIMIT = 10;
|
|
6
48
|
const MAX_LIST_LIMIT = 200;
|
|
@@ -124,7 +166,7 @@ async function dispatchServerQuery(descriptor, dispatcher, user, input) {
|
|
|
124
166
|
* paired detail handler right before the optimistic-lock write), dispatches with a
|
|
125
167
|
* run-scoped idempotency key, and — on success — re-reads through the detail handler so the
|
|
126
168
|
* returned data goes through the field-level read filter (the raw write result does not). */
|
|
127
|
-
async function dispatchServerWrite(descriptor, dispatcher, user, input, runId, toolCallId) {
|
|
169
|
+
async function dispatchServerWrite(descriptor, dispatcher, user, input, runId, toolCallId, classifyFailure) {
|
|
128
170
|
let payload = input;
|
|
129
171
|
if (descriptor.injectsVersion) {
|
|
130
172
|
const id = input["id"];
|
|
@@ -144,7 +186,12 @@ async function dispatchServerWrite(descriptor, dispatcher, user, input, runId, t
|
|
|
144
186
|
const requestId = `${runId}:${toolCallId}`;
|
|
145
187
|
const result = await dispatcher.write(descriptor.qn, payload, user, requestId);
|
|
146
188
|
if (!result.isSuccess) {
|
|
147
|
-
return {
|
|
189
|
+
return classifyFailure({
|
|
190
|
+
code: result.error.code,
|
|
191
|
+
httpStatus: result.error.httpStatus,
|
|
192
|
+
message: result.error.message,
|
|
193
|
+
retryAfterSeconds: result.error.retryAfterSeconds,
|
|
194
|
+
}, result.error);
|
|
148
195
|
}
|
|
149
196
|
const { id, version } = extractIdAndVersion(result.data);
|
|
150
197
|
const scalars = { ...(id !== undefined && { id }), ...(version !== undefined && { version }) };
|
|
@@ -230,10 +277,13 @@ function dispatchClient(descriptor, input) {
|
|
|
230
277
|
/** Executes one LLM-issued tool call. Never throws — a missing tool, a missing argument, a
|
|
231
278
|
* disallowed filter field, or a rejected handler call (cap check, validation, ...) all come
|
|
232
279
|
* back as `{ ok: false }` so the agent loop can feed the error back to the model instead of
|
|
233
|
-
* crashing.
|
|
280
|
+
* crashing. Client errors (< 500) keep their message and carry a `code`; 429 adds
|
|
281
|
+
* `retryable`. Server-side failures (>= 500, unknown throws) are logged and returned as a
|
|
282
|
+
* generic message so internals never reach the model. `request.user` must be the caller's real identity — every server-kind tool runs
|
|
234
283
|
* through the same permission-checked dispatcher pipeline as a normal HTTP request. */
|
|
235
284
|
export async function dispatchToolCall(request) {
|
|
236
285
|
const { dispatcher, user, toolName, input, dispatchTable, runId, toolCallId } = request;
|
|
286
|
+
const classifyFailure = createFailureClassifier(toolName, request.log ?? fallbackLog);
|
|
237
287
|
const descriptor = dispatchTable.get(toolName);
|
|
238
288
|
if (!descriptor) {
|
|
239
289
|
return { ok: false, error: `Unknown tool "${toolName}"` };
|
|
@@ -252,11 +302,11 @@ export async function dispatchToolCall(request) {
|
|
|
252
302
|
case "server":
|
|
253
303
|
return descriptor.op === "query"
|
|
254
304
|
? await dispatchServerQuery(descriptor, dispatcher, user, input)
|
|
255
|
-
: await dispatchServerWrite(descriptor, dispatcher, user, input, runId, toolCallId);
|
|
305
|
+
: await dispatchServerWrite(descriptor, dispatcher, user, input, runId, toolCallId, classifyFailure);
|
|
256
306
|
}
|
|
257
307
|
});
|
|
258
308
|
}
|
|
259
309
|
catch (err) {
|
|
260
|
-
return
|
|
310
|
+
return classifyFailure(toolFailureOf(err), err);
|
|
261
311
|
}
|
|
262
312
|
}
|
package/dist/audit/feature.js
CHANGED
|
@@ -27,7 +27,7 @@ export function createAuditFeature() {
|
|
|
27
27
|
recommended: false,
|
|
28
28
|
});
|
|
29
29
|
r.translations({ keys: AUDIT_I18N });
|
|
30
|
-
r.requires("tenant", "user", "config");
|
|
30
|
+
r.requires({ features: ["tenant", "user", "config"] });
|
|
31
31
|
// Registered so ops tools/MSPs can discover the type — escape-hatch-audit-sink.ts appends it via the low-level event-store API.
|
|
32
32
|
r.defineEvent("escape-hatch-used", escapeHatchUsedSchema, { piiFields: "none" });
|
|
33
33
|
r.config("escapeHatchRetentionDays", createSystemConfig("number", {
|
|
@@ -18,7 +18,6 @@ export declare const AuthHandlers: {
|
|
|
18
18
|
readonly inviteAcceptWithLogin: "auth-email-password:write:invite-accept-with-login";
|
|
19
19
|
readonly inviteSignupComplete: "auth-email-password:write:invite-signup-complete";
|
|
20
20
|
readonly switchTenantMfaGate: "auth-email-password:write:switch-tenant-mfa-gate";
|
|
21
|
-
readonly inviteCancel: "auth-email-password:write:invite-cancel";
|
|
22
21
|
};
|
|
23
22
|
export declare const AuthQueries: {
|
|
24
23
|
readonly signupRegistrationStatus: "auth-email-password:query:signup-registration-status";
|
|
@@ -36,7 +36,6 @@ export const AuthHandlers = {
|
|
|
36
36
|
// inviteAccept: logged-in user → POST token (membership-add)
|
|
37
37
|
// inviteAcceptWithLogin: anon user with existing email → POST token + email + password
|
|
38
38
|
// inviteSignupComplete: anon user with new email → POST token + password
|
|
39
|
-
// inviteCancel: admin cancels a pending invite
|
|
40
39
|
inviteCreate: "auth-email-password:write:invite-create",
|
|
41
40
|
// System-only variant (access.system) that may carry global roles — see
|
|
42
41
|
// invite-create.write.ts. Dispatched in-process by runBootstrap.
|
|
@@ -45,7 +44,6 @@ export const AuthHandlers = {
|
|
|
45
44
|
inviteAcceptWithLogin: "auth-email-password:write:invite-accept-with-login",
|
|
46
45
|
inviteSignupComplete: "auth-email-password:write:invite-signup-complete",
|
|
47
46
|
switchTenantMfaGate: "auth-email-password:write:switch-tenant-mfa-gate",
|
|
48
|
-
inviteCancel: "auth-email-password:write:invite-cancel",
|
|
49
47
|
};
|
|
50
48
|
// Qualified query names. Anonymous-readable status so the (unauthenticated)
|
|
51
49
|
// signup page can decide whether to show its own link/form.
|
|
@@ -82,13 +82,13 @@ export function createAuthEmailPasswordFeature(opts = {}) {
|
|
|
82
82
|
{ key: "accountUnlock", label: "Account-Unlock-Flow", type: "boolean", default: false },
|
|
83
83
|
],
|
|
84
84
|
});
|
|
85
|
-
r.requires("user");
|
|
86
|
-
r.requires("tenant");
|
|
85
|
+
r.requires({ features: ["user"] });
|
|
86
|
+
r.requires({ features: ["tenant"] });
|
|
87
87
|
// All five magic-link flows (reset/verify/signup/invite/unlock) dispatch
|
|
88
88
|
// via ctx.notify → delivery must be mounted. Fail closed at boot instead
|
|
89
89
|
// of silently dropping the mail.
|
|
90
90
|
if (sendsMagicLinkMail(opts))
|
|
91
|
-
r.requires("delivery");
|
|
91
|
+
r.requires({ features: ["delivery"] });
|
|
92
92
|
r.envSchema(authEmailPasswordEnvSchema);
|
|
93
93
|
r.translations({ keys: AUTH_EMAIL_PASSWORD_MAIL_I18N });
|
|
94
94
|
// Declared unconditionally: apps also issue the activation link from their
|
|
@@ -176,6 +176,7 @@ export function createInviteCreateHandler(opts) {
|
|
|
176
176
|
name: "invite-create",
|
|
177
177
|
schema: InviteCreateSchema,
|
|
178
178
|
access: { roles: access.admin },
|
|
179
|
+
agent: { expose: false },
|
|
179
180
|
description: "Invites an email address into the caller's tenant with a chosen membership role, creating or reusing the invitation and mailing the invitee a fresh accept link; re-inviting the same address invalidates the previous link.",
|
|
180
181
|
handler: async (event, ctx) => {
|
|
181
182
|
const roleRejection = checkAssignableInviteRole(opts, event.user.roles, event.payload.role, ctx);
|
package/dist/auth-mfa/feature.js
CHANGED
|
@@ -86,12 +86,12 @@ export function createAuthMfaFeature(opts) {
|
|
|
86
86
|
// shared variable; an app mounting auth-mfa without `secrets` still gets the
|
|
87
87
|
// KEK slot (and its _CIPHERTEXT twin) declared.
|
|
88
88
|
r.envSchema(secretsEnvSchema);
|
|
89
|
-
r.requires("user");
|
|
90
|
-
r.requires("config");
|
|
89
|
+
r.requires({ features: ["user"] });
|
|
90
|
+
r.requires({ features: ["config"] });
|
|
91
91
|
// verify.write.ts dispatches "tenant:query:memberships" — without this
|
|
92
92
|
// the app boots clean and the first MFA login dies at runtime on an
|
|
93
93
|
// unknown handler instead of failing at mount time.
|
|
94
|
-
r.requires("tenant");
|
|
94
|
+
r.requires({ features: ["tenant"] });
|
|
95
95
|
r.config("required", mfaRequiredConfigKey(opts.requiredPolicy));
|
|
96
96
|
// Declarative secretMint screen — mint (no input) -> reveal QR/secret/
|
|
97
97
|
// recovery-codes -> confirm with a code. App places it via r.nav in its
|
|
@@ -112,7 +112,7 @@ export function createAuthMfaFeature(opts) {
|
|
|
112
112
|
},
|
|
113
113
|
handler: (payload, ctx) => mfaReencryptJob(payload, ctx, ctx.db.unsafeRaw()),
|
|
114
114
|
});
|
|
115
|
-
r.entity("user-mfa", userMfaEntity);
|
|
115
|
+
r.entity({ name: "user-mfa", ...userMfaEntity });
|
|
116
116
|
// Late-bound by run-prod-app once the sessions feature (if mounted) has
|
|
117
117
|
// produced a concrete `sessionRevokeAllOthers` callback — mirrors
|
|
118
118
|
// sessions' own bindAutoRevokeOnPasswordChange. Shared by every handler
|
|
@@ -8,7 +8,7 @@ import { defineFeature, EXT_USER_DATA } from "@cosmicdrift/kumiko-framework/engi
|
|
|
8
8
|
import { userMfaDeleteHook, userMfaExportHook } from "./hooks.js";
|
|
9
9
|
export const authMfaUserDataFeature = defineFeature("auth-mfa-user-data", (r) => {
|
|
10
10
|
r.describe("GDPR (Art. 20 export / Art. 17 erasure) coverage for the `auth-mfa` feature's `user-mfa` entity. Mounts the EXT_USER_DATA export + delete hooks so 2FA enrollment status is included in the user-data export bundle and a user's TOTP secret + recovery codes are hard-deleted (via executor.forget, rebuild-safe) on a data-subject erasure request. Kept separate from `auth-mfa` so consumers without the user-data-rights pipeline don't pull a hard dependency — requires `user-data-rights`, optionalRequires `auth-mfa`.");
|
|
11
|
-
r.requires("user-data-rights");
|
|
11
|
+
r.requires({ features: ["user-data-rights"] });
|
|
12
12
|
r.optionalRequires("auth-mfa");
|
|
13
13
|
r.useExtension(EXT_USER_DATA, "user-mfa", {
|
|
14
14
|
export: userMfaExportHook,
|
|
@@ -22,7 +22,7 @@ function isConfirmableSubscriptionStatus(status) {
|
|
|
22
22
|
return status === SubscriptionStatuses.active || status === SubscriptionStatuses.trialing;
|
|
23
23
|
}
|
|
24
24
|
export function registerConsumerProtection(r, options, consumerProtection) {
|
|
25
|
-
r.requires("template-resolver", "delivery", "user", "tenant");
|
|
25
|
+
r.requires({ features: ["template-resolver", "delivery", "user", "tenant"] });
|
|
26
26
|
registerConsumerProtectionMails(r);
|
|
27
27
|
registerContractTermination(r, options, consumerProtection);
|
|
28
28
|
r.defineEvent(CHECKOUT_CONSENT_RECORDED_EVENT_SHORT, checkoutConsentRecordedPayloadSchema, {
|
|
@@ -96,7 +96,7 @@ export function createBillingFoundationFeature(options = {}) {
|
|
|
96
96
|
category: "billing",
|
|
97
97
|
recommended: false,
|
|
98
98
|
});
|
|
99
|
-
r.requires("tenant-lifecycle", "compliance-profiles");
|
|
99
|
+
r.requires({ features: ["tenant-lifecycle", "compliance-profiles"] });
|
|
100
100
|
// 5 fine-grained domain-events. All 5 share the same payload shape (a
|
|
101
101
|
// subscription-state snapshot); the event type tags what happened.
|
|
102
102
|
// Future consumers (billing-history, accounting) listen directly on
|
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
import { deleteMany, runInOwnTransaction } from "@cosmicdrift/kumiko-framework/db";
|
|
1
|
+
import { deleteMany, isUniqueViolation, runInOwnTransaction, } from "@cosmicdrift/kumiko-framework/db";
|
|
2
2
|
import { reraiseAsKumikoError } from "@cosmicdrift/kumiko-framework/errors";
|
|
3
3
|
import { appendEventInTenantDb, getStreamVersionInTenantDb, } from "@cosmicdrift/kumiko-framework/event-store";
|
|
4
4
|
import { generateId } from "@cosmicdrift/kumiko-framework/utils";
|
|
5
5
|
import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
|
|
6
6
|
import { rollingCapAggregateId } from "./aggregate-id.js";
|
|
7
7
|
import { applyCapDeltaOn, readRollingCapUsage, resolveCapCounterScope, retryCounterWriteOnVersionConflict, retryOnStreamVersionConflict, } from "./book-cap-usage.js";
|
|
8
|
-
import { CAP_COUNTER_ROLLING_AGGREGATE_TYPE, CAP_RESERVATION_TTL_MINUTES, ROLLING_INCREMENTED_EVENT_QN, ROLLING_RELEASED_EVENT_QN, } from "./constants.js";
|
|
8
|
+
import { CAP_COUNTER_ROLLING_AGGREGATE_TYPE, CAP_RESERVATION_TTL_MINUTES, CAP_SETTLED_TOMBSTONE_TTL_MINUTES, ROLLING_INCREMENTED_EVENT_QN, ROLLING_RELEASED_EVENT_QN, } from "./constants.js";
|
|
9
9
|
import { capReservationsTable } from "./tables.js";
|
|
10
10
|
const NOTHING_TO_RELEASE = { isSuccess: true, data: {} };
|
|
11
11
|
async function insertReservationRow(txDb, user, id, draft) {
|
|
@@ -19,6 +19,7 @@ async function insertReservationRow(txDb, user, id, draft) {
|
|
|
19
19
|
amount: draft.amount,
|
|
20
20
|
expiresAt: now.add({ minutes: CAP_RESERVATION_TTL_MINUTES }),
|
|
21
21
|
createdAt: now,
|
|
22
|
+
settledAt: null,
|
|
22
23
|
});
|
|
23
24
|
}
|
|
24
25
|
function toHandle(ctx, id, outsideTransaction) {
|
|
@@ -94,7 +95,11 @@ export async function reserveRollingCap(ctx, options, outsideTransaction = true)
|
|
|
94
95
|
// Deleting the row takes its row lock inside the handler transaction, so a release started after a
|
|
95
96
|
// failed COMMIT waits for the real outcome and finds the row gone when the commit went through.
|
|
96
97
|
async function confirmCapReservation(confirm, id) {
|
|
97
|
-
await deleteMany(confirm.tx, capReservationsTable, {
|
|
98
|
+
await deleteMany(confirm.tx, capReservationsTable, {
|
|
99
|
+
id,
|
|
100
|
+
tenantId: confirm.tenantId,
|
|
101
|
+
settledAt: null,
|
|
102
|
+
});
|
|
98
103
|
}
|
|
99
104
|
async function giveBackCalendar(txDb, user, row) {
|
|
100
105
|
if (row.periodStartIso === null) {
|
|
@@ -113,7 +118,10 @@ async function giveBackRolling(txDb, user, row) {
|
|
|
113
118
|
}
|
|
114
119
|
async function giveBackReservation(txDb, user, id) {
|
|
115
120
|
const ownRow = { id, tenantId: user.tenantId };
|
|
116
|
-
const row = await txDb.fetchOne(capReservationsTable,
|
|
121
|
+
const row = await txDb.fetchOne(capReservationsTable, {
|
|
122
|
+
...ownRow,
|
|
123
|
+
settledAt: null,
|
|
124
|
+
});
|
|
117
125
|
if (!row)
|
|
118
126
|
return NOTHING_TO_RELEASE;
|
|
119
127
|
const claimed = await claimReservationRow(txDb, user, row);
|
|
@@ -127,9 +135,10 @@ async function giveBackReservation(txDb, user, id) {
|
|
|
127
135
|
await txDb.deleteMany(capReservationsTable, ownRow);
|
|
128
136
|
return given;
|
|
129
137
|
}
|
|
130
|
-
// Waits for a confirm still holding the row; false means that confirm
|
|
138
|
+
// Waits for a confirm still holding the row; false means that confirm, a settle or another release
|
|
139
|
+
// won (Postgres re-checks settled_at after the lock wait).
|
|
131
140
|
async function claimReservationRow(txDb, user, row) {
|
|
132
|
-
const claimed = await txDb.updateMany(capReservationsTable, { expiresAt: row.expiresAt }, { id: row.id, tenantId: user.tenantId });
|
|
141
|
+
const claimed = await txDb.updateMany(capReservationsTable, { expiresAt: row.expiresAt }, { id: row.id, tenantId: user.tenantId, settledAt: null });
|
|
133
142
|
return claimed.length > 0;
|
|
134
143
|
}
|
|
135
144
|
export async function releaseCapReservation(ctx, id, outsideTransaction = true) {
|
|
@@ -143,39 +152,78 @@ function bookCalendarDelta(txDb, user, period, delta) {
|
|
|
143
152
|
return Promise.resolve({ isSuccess: true, data: {} });
|
|
144
153
|
return applyCapDeltaOn(txDb, user, { ...period, amount: Math.abs(delta) }, delta > 0 ? "add" : "subtract");
|
|
145
154
|
}
|
|
146
|
-
|
|
147
|
-
|
|
155
|
+
async function settleInTransaction(txDb, user, id, options) {
|
|
156
|
+
const ownRow = { id, tenantId: user.tenantId };
|
|
157
|
+
const open = await txDb.fetchOne(capReservationsTable, {
|
|
158
|
+
...ownRow,
|
|
159
|
+
settledAt: null,
|
|
160
|
+
});
|
|
161
|
+
if (open && (await claimReservationRow(txDb, user, open))) {
|
|
162
|
+
const booked = await bookCalendarDelta(txDb, user, options, options.actualAmount - open.amount);
|
|
163
|
+
if (!booked.isSuccess)
|
|
164
|
+
return booked;
|
|
165
|
+
const now = Temporal.Now.instant();
|
|
166
|
+
await txDb.updateMany(capReservationsTable, { settledAt: now, expiresAt: now.add({ minutes: CAP_SETTLED_TOMBSTONE_TTL_MINUTES }) }, ownRow);
|
|
167
|
+
return booked;
|
|
168
|
+
}
|
|
169
|
+
// The claim lost to a concurrent settle or release: a tombstone means the amount is booked.
|
|
170
|
+
if (await txDb.fetchOne(capReservationsTable, ownRow)) {
|
|
171
|
+
return NOTHING_TO_RELEASE;
|
|
172
|
+
}
|
|
173
|
+
// Row swept or released: its amount was given back, so the full actual amount is booked and a
|
|
174
|
+
// tombstone makes a repeated settle a no-op. A parallel settle inserting first fails this insert.
|
|
175
|
+
const now = Temporal.Now.instant();
|
|
176
|
+
await txDb.insertOne(capReservationsTable, {
|
|
177
|
+
...ownRow,
|
|
178
|
+
capName: options.capName,
|
|
179
|
+
kind: "calendar",
|
|
180
|
+
periodStartIso: options.periodStartIso,
|
|
181
|
+
amount: options.actualAmount,
|
|
182
|
+
expiresAt: now.add({ minutes: CAP_SETTLED_TOMBSTONE_TTL_MINUTES }),
|
|
183
|
+
createdAt: now,
|
|
184
|
+
settledAt: now,
|
|
185
|
+
});
|
|
186
|
+
return bookCalendarDelta(txDb, user, options, options.actualAmount);
|
|
187
|
+
}
|
|
188
|
+
// The failed tombstone insert aborts the whole attempt, so nothing was booked; the repeat sees the
|
|
189
|
+
// winner's tombstone.
|
|
190
|
+
async function retryOnceOnTombstoneRace(attempt) {
|
|
191
|
+
try {
|
|
192
|
+
return await attempt();
|
|
193
|
+
}
|
|
194
|
+
catch (error) {
|
|
195
|
+
if (!isUniqueViolation(error))
|
|
196
|
+
throw error;
|
|
197
|
+
return attempt();
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
// Replaces the reserved amount by the actual one in a single transaction and keeps the row as a
|
|
201
|
+
// settled tombstone, so a repeated settle (retry, lost ack) books nothing twice.
|
|
148
202
|
export async function settleCalendarCapReservation(ctx, id, options, outsideTransaction = true) {
|
|
149
203
|
if (!Number.isInteger(options.actualAmount) || options.actualAmount < 0) {
|
|
150
204
|
throw new Error(`cap-counter: settle amount must be a non-negative integer, got ${options.actualAmount}`);
|
|
151
205
|
}
|
|
152
206
|
const { user, writeDb } = resolveCapCounterScope(ctx, outsideTransaction);
|
|
153
|
-
const settled = await retryCounterWriteOnVersionConflict(() => runInOwnTransaction(writeDb,
|
|
154
|
-
const row = await txDb.fetchOne(capReservationsTable, {
|
|
155
|
-
id,
|
|
156
|
-
tenantId: user.tenantId,
|
|
157
|
-
});
|
|
158
|
-
const claimed = row ? await claimReservationRow(txDb, user, row) : false;
|
|
159
|
-
if (!row || !claimed) {
|
|
160
|
-
return bookCalendarDelta(txDb, user, options, options.actualAmount);
|
|
161
|
-
}
|
|
162
|
-
const booked = await bookCalendarDelta(txDb, user, options, options.actualAmount - row.amount);
|
|
163
|
-
if (!booked.isSuccess)
|
|
164
|
-
return booked;
|
|
165
|
-
await txDb.deleteMany(capReservationsTable, { id, tenantId: user.tenantId });
|
|
166
|
-
return booked;
|
|
167
|
-
}));
|
|
207
|
+
const settled = await retryCounterWriteOnVersionConflict(() => retryOnceOnTombstoneRace(() => runInOwnTransaction(writeDb, (txDb) => settleInTransaction(txDb, user, id, options))));
|
|
168
208
|
if (!settled.isSuccess)
|
|
169
209
|
throw reraiseAsKumikoError(settled.error);
|
|
170
210
|
}
|
|
171
211
|
// A failed release leaves its row behind; giving it back once the TTL passed bounds how long that
|
|
172
|
-
// capacity stays counted.
|
|
212
|
+
// capacity stays counted. Expired settled tombstones are only deleted: their amount stays booked.
|
|
173
213
|
export async function releaseExpiredCapReservations(ctx, capName, outsideTransaction = true) {
|
|
174
214
|
const { user, writeDb } = resolveCapCounterScope(ctx, outsideTransaction);
|
|
175
|
-
const
|
|
215
|
+
const expiredWhere = {
|
|
176
216
|
tenantId: user.tenantId,
|
|
177
217
|
capName,
|
|
178
218
|
expiresAt: { lt: Temporal.Now.instant() },
|
|
219
|
+
};
|
|
220
|
+
await writeDb.deleteMany(capReservationsTable, {
|
|
221
|
+
...expiredWhere,
|
|
222
|
+
settledAt: { ne: null },
|
|
223
|
+
});
|
|
224
|
+
const expired = await writeDb.selectMany(capReservationsTable, {
|
|
225
|
+
...expiredWhere,
|
|
226
|
+
settledAt: null,
|
|
179
227
|
});
|
|
180
228
|
for (const { id } of expired) {
|
|
181
229
|
try {
|
|
@@ -6,6 +6,7 @@ export declare const ROLLING_INCREMENTED_EVENT_QN: "cap-counter:event:rolling-in
|
|
|
6
6
|
export declare const ROLLING_RELEASED_EVENT_SHORT: "rolling-released";
|
|
7
7
|
export declare const ROLLING_RELEASED_EVENT_QN: "cap-counter:event:rolling-released";
|
|
8
8
|
export declare const CAP_RESERVATION_TTL_MINUTES = 60;
|
|
9
|
+
export declare const CAP_SETTLED_TOMBSTONE_TTL_MINUTES: number;
|
|
9
10
|
export declare const CapCounterHandlers: {
|
|
10
11
|
readonly increment: "cap-counter:write:increment";
|
|
11
12
|
readonly incrementRolling: "cap-counter:write:increment-rolling";
|
|
@@ -22,6 +22,8 @@ export const ROLLING_RELEASED_EVENT_QN = "cap-counter:event:rolling-released";
|
|
|
22
22
|
// A reservation row older than this is treated as a lost release and given back before the next
|
|
23
23
|
// reservation for the same cap, which bounds the over-count a failed release can leave behind.
|
|
24
24
|
export const CAP_RESERVATION_TTL_MINUTES = 60;
|
|
25
|
+
// A settled reservation stays as a tombstone this long so a retried or lost-ack settle stays a no-op.
|
|
26
|
+
export const CAP_SETTLED_TOMBSTONE_TTL_MINUTES = 24 * 60;
|
|
25
27
|
// Qualified write handler names (QN format: scope:type:name).
|
|
26
28
|
export const CapCounterHandlers = {
|
|
27
29
|
increment: "cap-counter:write:increment",
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// cap-counter — Counter-Storage + Increment-API + Soft-Warn-State.
|
|
2
2
|
//
|
|
3
3
|
// **Was diese Feature liefert:**
|
|
4
|
-
// 1. r.entity("cap-counter") — Counter-Rows pro (tenant, capName,
|
|
4
|
+
// 1. r.entity({ name: "cap-counter" }) — Counter-Rows pro (tenant, capName,
|
|
5
5
|
// period) für Calendar-Period-Caps (Mails/Monat, Egress/Monat).
|
|
6
6
|
// 2. increment-Handler — atomic counter increment via deterministic
|
|
7
7
|
// aggregate-id (try-create / executor-update). Race-frei via
|
|
@@ -60,7 +60,7 @@ export const capCounterFeature = defineFeature(CAP_COUNTER_FEATURE, (r) => {
|
|
|
60
60
|
category: "operations",
|
|
61
61
|
recommended: false,
|
|
62
62
|
});
|
|
63
|
-
r.entity("cap-counter", capCounterEntity);
|
|
63
|
+
r.entity({ name: "cap-counter", ...capCounterEntity });
|
|
64
64
|
r.storeTable(capReservationsTableMeta, {
|
|
65
65
|
reason: "coordination.cap_reservation_ledger",
|
|
66
66
|
});
|
|
@@ -2,8 +2,9 @@ import { defineUnmanagedTable, index, instant, integer, table as pgTable, text,
|
|
|
2
2
|
const CAP_RESERVATIONS_TENANT_CAP_EXPIRES_INDEX = "store_cap_reservations_tenant_id_cap_name_expires_at_idx";
|
|
3
3
|
// One row per capacity that was booked before a handler transaction and is not settled yet. The
|
|
4
4
|
// handler transaction deletes it on its own commit (confirm); a release or the TTL sweep deletes it
|
|
5
|
-
// together with the counter decrement.
|
|
6
|
-
//
|
|
5
|
+
// together with the counter decrement. A settle keeps the row as a tombstone (settled_at set) until
|
|
6
|
+
// its shortened TTL passes, so a repeated settle or a late release finds it and does nothing.
|
|
7
|
+
// Unmanaged: the rows are a coordination aid, not state with an event history.
|
|
7
8
|
export const capReservationsTable = pgTable("store_cap_reservations", {
|
|
8
9
|
id: uuid("id").primaryKey(),
|
|
9
10
|
tenantId: uuid("tenant_id").notNull(),
|
|
@@ -14,6 +15,7 @@ export const capReservationsTable = pgTable("store_cap_reservations", {
|
|
|
14
15
|
amount: integer("amount").notNull(),
|
|
15
16
|
expiresAt: instant("expires_at").notNull(),
|
|
16
17
|
createdAt: instant("created_at").notNull(),
|
|
18
|
+
settledAt: instant("settled_at"),
|
|
17
19
|
}, (t) => [index(CAP_RESERVATIONS_TENANT_CAP_EXPIRES_INDEX).on(t.tenantId, t.capName, t.expiresAt)]);
|
|
18
20
|
export const capReservationsTableMeta = defineUnmanagedTable({
|
|
19
21
|
tableName: "store_cap_reservations",
|
|
@@ -32,5 +34,6 @@ export const capReservationsTableMeta = defineUnmanagedTable({
|
|
|
32
34
|
{ name: "amount", pgType: "integer", notNull: true },
|
|
33
35
|
{ name: "expires_at", pgType: "timestamptz", notNull: true },
|
|
34
36
|
{ name: "created_at", pgType: "timestamptz", notNull: true },
|
|
37
|
+
{ name: "settled_at", pgType: "timestamptz", notNull: false },
|
|
35
38
|
],
|
|
36
39
|
});
|
|
@@ -43,9 +43,9 @@ export function createCapOverviewFeature(opts) {
|
|
|
43
43
|
recommended: false,
|
|
44
44
|
});
|
|
45
45
|
r.systemScope();
|
|
46
|
-
r.requires("tenant");
|
|
47
|
-
r.requires("tier-engine");
|
|
48
|
-
r.requires("billing-foundation");
|
|
46
|
+
r.requires({ features: ["tenant"] });
|
|
47
|
+
r.requires({ features: ["tier-engine"] });
|
|
48
|
+
r.requires({ features: ["billing-foundation"] });
|
|
49
49
|
r.queryHandler(createTenantCapsListQuery(opts.caps, listCaps));
|
|
50
50
|
r.queryHandler(createCapsUsageQuery(opts.caps, usageRoles));
|
|
51
51
|
r.queryHandler(tenantOptionsQuery);
|
|
@@ -9,8 +9,8 @@ export function createChannelDiscordFeature(options = {}) {
|
|
|
9
9
|
category: "notifications",
|
|
10
10
|
recommended: false,
|
|
11
11
|
});
|
|
12
|
-
r.requires("delivery");
|
|
13
|
-
r.requires("secrets");
|
|
12
|
+
r.requires({ features: ["delivery"] });
|
|
13
|
+
r.requires({ features: ["secrets"] });
|
|
14
14
|
const webhookTarget = resolveChatWebhookTarget({
|
|
15
15
|
defaultAllowedHosts: DISCORD_DEFAULT_ALLOWED_HOSTS,
|
|
16
16
|
requiredPathPrefix: DISCORD_WEBHOOK_PATH_PREFIX,
|
|
@@ -10,7 +10,7 @@ export function createChannelEmailFeature(options) {
|
|
|
10
10
|
category: "notifications",
|
|
11
11
|
recommended: false,
|
|
12
12
|
});
|
|
13
|
-
r.requires("delivery");
|
|
13
|
+
r.requires({ features: ["delivery"] });
|
|
14
14
|
r.translations({ keys: { "delivery.channel.email": { en: "Email" } } });
|
|
15
15
|
r.useExtension(DELIVERY_CHANNEL_EXTENSION, EMAIL_CHANNEL_NAME, {
|
|
16
16
|
mode: channel.mode,
|
|
@@ -14,7 +14,7 @@ export function createChannelInAppFeature() {
|
|
|
14
14
|
category: "notifications",
|
|
15
15
|
recommended: false,
|
|
16
16
|
});
|
|
17
|
-
r.requires("delivery");
|
|
17
|
+
r.requires({ features: ["delivery"] });
|
|
18
18
|
r.storeTable(inAppMessagesTableMeta, {
|
|
19
19
|
reason: "read_side.in_app_messages",
|
|
20
20
|
});
|
|
@@ -10,7 +10,7 @@ export function createChannelPushFeature(options) {
|
|
|
10
10
|
category: "notifications",
|
|
11
11
|
recommended: false,
|
|
12
12
|
});
|
|
13
|
-
r.requires("delivery");
|
|
13
|
+
r.requires({ features: ["delivery"] });
|
|
14
14
|
r.translations({ keys: { "delivery.channel.push": { en: "Push" } } });
|
|
15
15
|
r.useExtension(DELIVERY_CHANNEL_EXTENSION, "push", {
|
|
16
16
|
mode: channel.mode,
|
|
@@ -9,8 +9,8 @@ export function createChannelSlackFeature(options = {}) {
|
|
|
9
9
|
category: "notifications",
|
|
10
10
|
recommended: false,
|
|
11
11
|
});
|
|
12
|
-
r.requires("delivery");
|
|
13
|
-
r.requires("secrets");
|
|
12
|
+
r.requires({ features: ["delivery"] });
|
|
13
|
+
r.requires({ features: ["secrets"] });
|
|
14
14
|
const webhookTarget = resolveChatWebhookTarget({ defaultAllowedHosts: SLACK_DEFAULT_ALLOWED_HOSTS }, options);
|
|
15
15
|
const webhooks = r.secretNamespace("webhooks", {
|
|
16
16
|
label: { en: "Slack webhooks", de: "Slack-Webhooks", es: "Webhooks de Slack" },
|
|
@@ -9,8 +9,8 @@ export function createChannelTeamsFeature(options = {}) {
|
|
|
9
9
|
category: "notifications",
|
|
10
10
|
recommended: false,
|
|
11
11
|
});
|
|
12
|
-
r.requires("delivery");
|
|
13
|
-
r.requires("secrets");
|
|
12
|
+
r.requires({ features: ["delivery"] });
|
|
13
|
+
r.requires({ features: ["secrets"] });
|
|
14
14
|
const webhookTarget = resolveChatWebhookTarget({ defaultAllowedHosts: TEAMS_DEFAULT_ALLOWED_HOSTS }, options);
|
|
15
15
|
const webhooks = r.secretNamespace("webhooks", {
|
|
16
16
|
label: {
|
|
@@ -10,8 +10,8 @@ export function createChannelTelegramFeature(options = {}) {
|
|
|
10
10
|
category: "notifications",
|
|
11
11
|
recommended: false,
|
|
12
12
|
});
|
|
13
|
-
r.requires("delivery");
|
|
14
|
-
r.requires("secrets");
|
|
13
|
+
r.requires({ features: ["delivery"] });
|
|
14
|
+
r.requires({ features: ["secrets"] });
|
|
15
15
|
const botToken = r.secret("botToken", {
|
|
16
16
|
label: { en: "Telegram bot token" },
|
|
17
17
|
hint: {
|
|
@@ -22,7 +22,7 @@ export function createComplianceProfilesFeature(options) {
|
|
|
22
22
|
// Context, Profile-Selection ist eigene Entity, sub-processor-Liste
|
|
23
23
|
// sind Constants. Wenn S1.4+ Cross-Feature-Reads dazukommen, kommt
|
|
24
24
|
// r.requires hier rein.
|
|
25
|
-
r.entity("tenant-compliance-profile", tenantComplianceProfileEntity);
|
|
25
|
+
r.entity({ name: "tenant-compliance-profile", ...tenantComplianceProfileEntity });
|
|
26
26
|
r.exposesApi("compliance.forTenant");
|
|
27
27
|
const handlers = {
|
|
28
28
|
setProfile: r.writeHandler({ ...setProfileWrite, access: { roles: resolvedAccess } }),
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// a separate feature (mirrors folders-user-data/notes-history-user-data)
|
|
8
8
|
// rather than folded into compliance-profiles: this is the one genuinely
|
|
9
9
|
// cross-tenant capability in the picker's orbit, and it alone needs
|
|
10
|
-
// r.systemScope() + a hard r.requires("tenant") — apps that only want the
|
|
10
|
+
// r.systemScope() + a hard r.requires({ features: ["tenant"] }) — apps that only want the
|
|
11
11
|
// per-tenant picker (the vast majority) stay unaffected.
|
|
12
12
|
import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
|
|
13
13
|
import { tenantsMissingProfileQuery } from "./handlers/tenants-missing-profile.query.js";
|
|
@@ -19,8 +19,8 @@ export const complianceProfilesOpsFeature = defineFeature("compliance-profiles-o
|
|
|
19
19
|
recommended: false,
|
|
20
20
|
});
|
|
21
21
|
r.systemScope();
|
|
22
|
-
r.requires("compliance-profiles");
|
|
23
|
-
r.requires("tenant");
|
|
22
|
+
r.requires({ features: ["compliance-profiles"] });
|
|
23
|
+
r.requires({ features: ["tenant"] });
|
|
24
24
|
const queries = {
|
|
25
25
|
tenantsMissingProfile: r.queryHandler(tenantsMissingProfileQuery),
|
|
26
26
|
};
|