@classytic/arc 2.15.4 → 2.16.1
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/README.md +1 -0
- package/bin/arc.js +12 -0
- package/dist/{BaseController-dx3m2J8V.mjs → BaseController-Vk8I7C0x.mjs} +61 -19
- package/dist/{HookSystem-Iiebom92.mjs → HookSystem-Cmf7-Etp.mjs} +8 -4
- package/dist/{QueryCache-D41bfdBB.d.mts → QueryCache-8YmAQ-zW.d.mts} +1 -1
- package/dist/{ResourceRegistry-CTERg_2x.mjs → ResourceRegistry-CzSzVK1m.mjs} +55 -9
- package/dist/audit/index.d.mts +1 -1
- package/dist/audit/index.mjs +4 -2
- package/dist/auth/index.d.mts +4 -4
- package/dist/auth/index.mjs +4 -4
- package/dist/auth/redis-session.d.mts +1 -1
- package/dist/{betterAuthOpenApi--M_i87dQ.mjs → betterAuthOpenApi-ClWxaceA.mjs} +10 -6
- package/dist/buildHandler-C_SkBNF3.mjs +302 -0
- package/dist/cache/index.d.mts +3 -3
- package/dist/cache/index.mjs +3 -3
- package/dist/{caching-SM8gghN6.mjs → caching-8MfrrUYU.mjs} +1 -1
- package/dist/cli/commands/describe.d.mts +35 -1
- package/dist/cli/commands/describe.mjs +52 -12
- package/dist/cli/commands/docs.d.mts +1 -4
- package/dist/cli/commands/docs.mjs +4 -16
- package/dist/cli/commands/generate.d.mts +2 -20
- package/dist/cli/commands/generate.mjs +1 -546
- package/dist/cli/commands/init.d.mts +2 -40
- package/dist/cli/commands/init.mjs +1 -3045
- package/dist/cli/commands/introspect.mjs +53 -64
- package/dist/cli/index.d.mts +2 -2
- package/dist/cli/index.mjs +2 -2
- package/dist/{constants-Cxde4rpC.mjs → constants-TrJVIJl0.mjs} +7 -0
- package/dist/core/index.d.mts +3 -3
- package/dist/core/index.mjs +5 -5
- package/dist/{core-CvmOqEms.mjs → core-wKeGXn38.mjs} +222 -44
- package/dist/createActionRouter-eiOFtnC3.mjs +288 -0
- package/dist/{createAggregationRouter-B0bPDf5b.mjs → createAggregationRouter-C4unhBnV.mjs} +3 -2
- package/dist/{createApp-PFegs47-.mjs → createApp-DdPBtmzp.mjs} +16 -14
- package/dist/{defineEvent-D5h7EvAx.mjs → defineEvent-NPKUJRt8.mjs} +1 -1
- package/dist/docs/index.d.mts +2 -2
- package/dist/docs/index.mjs +1 -1
- package/dist/{errorHandler-Bk-AGhkU.mjs → errorHandler-DNPzqaTT.mjs} +17 -14
- package/dist/errors-4ZloYFvm.d.mts +91 -0
- package/dist/{eventPlugin-CaKTYkYM.mjs → eventPlugin-CnJCUyM0.mjs} +1 -1
- package/dist/{eventPlugin-qXpqTebY.d.mts → eventPlugin-DjEx_J3j.d.mts} +1 -1
- package/dist/events/index.d.mts +3 -3
- package/dist/events/index.mjs +5 -5
- package/dist/events/transports/redis-stream-entry.d.mts +1 -1
- package/dist/events/transports/redis.d.mts +1 -1
- package/dist/factory/index.d.mts +2 -2
- package/dist/factory/index.mjs +4 -3
- package/dist/fastify-augmentation-CJm0vh-l.d.mts +42 -0
- package/dist/{fields-COhcH3fk.d.mts → fields-DqZLp5d6.d.mts} +1 -1
- package/dist/generate-CcM24hoq.mjs +654 -0
- package/dist/generate-cRnsafMS.d.mts +38 -0
- package/dist/hooks/index.d.mts +1 -1
- package/dist/hooks/index.mjs +1 -1
- package/dist/idempotency/index.d.mts +2 -2
- package/dist/idempotency/index.mjs +1 -1
- package/dist/idempotency/redis.d.mts +1 -1
- package/dist/{index-BTqLEvhu.d.mts → index-3L-adFJO.d.mts} +12 -12
- package/dist/{index-BstGxcc3.d.mts → index-CC_pgZjY.d.mts} +88 -17
- package/dist/{index-BswOSJCE.d.mts → index-DBDgy6Hw.d.mts} +430 -19
- package/dist/index.d.mts +6 -6
- package/dist/index.mjs +7 -8
- package/dist/init-D-vrTZ1G.d.mts +71 -0
- package/dist/init-DYzfniuX.mjs +3098 -0
- package/dist/integrations/event-gateway.d.mts +2 -2
- package/dist/integrations/event-gateway.mjs +1 -1
- package/dist/integrations/index.d.mts +2 -2
- package/dist/integrations/jobs.mjs +3 -3
- package/dist/integrations/mcp/index.d.mts +239 -7
- package/dist/integrations/mcp/index.mjs +2 -528
- package/dist/integrations/mcp/testing.d.mts +2 -2
- package/dist/integrations/mcp/testing.mjs +6 -10
- package/dist/integrations/streamline.mjs +26 -1
- package/dist/integrations/websocket-redis.d.mts +1 -1
- package/dist/integrations/websocket.d.mts +1 -1
- package/dist/integrations/websocket.mjs +1 -0
- package/dist/loadResourcesFromEntry-DCjYMgfC.mjs +51 -0
- package/dist/{resourceToTools-tFYUNmM0.mjs → mcpPlugin-Dbgz9_Rj.mjs} +1021 -318
- package/dist/{memory-UBydS5ku.mjs → memory-QOLe11D5.mjs} +2 -0
- package/dist/middleware/index.d.mts +1 -1
- package/dist/middleware/index.mjs +1 -1
- package/dist/{openapi-BHXhoX8O.mjs → openapi-BRnd3pT7.mjs} +82 -44
- package/dist/permissions/index.d.mts +2 -2
- package/dist/permissions/index.mjs +1 -1
- package/dist/{permissions-ohQyv50e.mjs → permissions-CTxMrreC.mjs} +2 -2
- package/dist/{pipe-Zr0KXjQe.mjs → pipe-DiCyvyPN.mjs} +1 -0
- package/dist/pipeline/index.d.mts +1 -1
- package/dist/pipeline/index.mjs +1 -1
- package/dist/plugins/index.d.mts +5 -59
- package/dist/plugins/index.mjs +10 -10
- package/dist/plugins/response-cache.mjs +5 -5
- package/dist/plugins/tracing-entry.d.mts +1 -1
- package/dist/plugins/tracing-entry.mjs +1 -1
- package/dist/{pluralize-DQgqgifU.mjs → pluralize-B9M8xvy-.mjs} +2 -1
- package/dist/preloadResources-BOXzPhKU.mjs +45 -0
- package/dist/presets/filesUpload.d.mts +4 -4
- package/dist/presets/filesUpload.mjs +2 -2
- package/dist/presets/index.d.mts +1 -1
- package/dist/presets/index.mjs +1 -1
- package/dist/presets/multiTenant.d.mts +1 -1
- package/dist/presets/multiTenant.mjs +4 -3
- package/dist/presets/search.d.mts +2 -2
- package/dist/presets/search.mjs +1 -1
- package/dist/{presets-BbkjdPeH.mjs → presets-C9BE6WaZ.mjs} +2 -2
- package/dist/{queryCachePlugin-m1XsgAIJ.mjs → queryCachePlugin-B4XMSSe7.mjs} +2 -2
- package/dist/{queryCachePlugin-CqMdLI2-.d.mts → queryCachePlugin-Do4P_WBC.d.mts} +2 -2
- package/dist/{redis-DiMkdHEl.d.mts → redis-cpqVb85w.d.mts} +1 -1
- package/dist/{redis-stream-D6HzR1Z_.d.mts → redis-stream-BuiAS3aE.d.mts} +1 -1
- package/dist/registry/index.d.mts +341 -2
- package/dist/registry/index.mjs +3 -3
- package/dist/registry-BrlHkrRS.mjs +582 -0
- package/dist/{routerShared-DrOa-26E.mjs → routerShared-ClV4cCaa.mjs} +40 -16
- package/dist/scope/index.d.mts +3 -3
- package/dist/scope/index.mjs +3 -3
- package/dist/{sse-Bz-5ZeTt.mjs → sse-jh5OqFjR.mjs} +1 -1
- package/dist/testing/index.d.mts +2 -25
- package/dist/testing/index.mjs +17 -51
- package/dist/testing/storageContract.d.mts +1 -1
- package/dist/types/index.d.mts +5 -5
- package/dist/types/storage.d.mts +1 -1
- package/dist/{types-BQsjgQzS.d.mts → types-BD0BCdf6.d.mts} +106 -12
- package/dist/{types-C_s5moIu.mjs → types-Bi0r0vjG.mjs} +53 -1
- package/dist/{types-CTYvcwHe.d.mts → types-CfapW2t8.d.mts} +42 -1
- package/dist/{types-DrBaUwyV.d.mts → types-Dw--SyTq.d.mts} +57 -11
- package/dist/utils/index.d.mts +1287 -2
- package/dist/utils/index.mjs +1 -1
- package/dist/{utils-_h9B3c57.mjs → utils-DPXZnZUx.mjs} +158 -41
- package/dist/{buildHandler-CcFOpJLh.mjs → validate-DFIP2VrM.mjs} +73 -302
- package/dist/{versioning-hmkPcDlX.d.mts → versioning-5H4930tj.d.mts} +1 -1
- package/package.json +22 -29
- package/skills/arc/SKILL.md +300 -706
- package/skills/arc/references/auth.md +19 -7
- package/skills/arc-code-review/SKILL.md +1 -1
- package/skills/arc-code-review/references/arc-cheatsheet.md +100 -322
- package/dist/createActionRouter-S3MLVYot.mjs +0 -220
- package/dist/index-bRjYu21O.d.mts +0 -1320
- package/dist/org/index.d.mts +0 -66
- package/dist/org/index.mjs +0 -486
- package/dist/org/types.d.mts +0 -82
- package/dist/org/types.mjs +0 -1
- package/dist/registry-I-ogLgL9.mjs +0 -46
- /package/dist/{EventTransport-CT_52aWU.d.mts → EventTransport-B1GFiYUy.d.mts} +0 -0
- /package/dist/{EventTransport-DLWoUMHy.mjs → EventTransport-ClEDfiMS.mjs} +0 -0
- /package/dist/{actionPermissions-CyUkQu6O.mjs → actionPermissions-Bjmvn7Eb.mjs} +0 -0
- /package/dist/{elevation-DgoeTyfX.mjs → elevation-Dci0AYLT.mjs} +0 -0
- /package/dist/{elevation-BXOWoGCF.d.mts → elevation-DvwtjDwu.d.mts} +0 -0
- /package/dist/{errorHandler-DFr45ZG4.d.mts → errorHandler-DuqTqeqn.d.mts} +0 -0
- /package/dist/{externalPaths-BD5nw6St.d.mts → externalPaths-ClykOQqa.d.mts} +0 -0
- /package/dist/{interface-beEtJyWM.d.mts → interface-BVkZsvEe.d.mts} +0 -0
- /package/dist/{interface-DfLGcus7.d.mts → interface-DoE6UoC2.d.mts} +0 -0
- /package/dist/{keys-CGcCbNyu.mjs → keys-DopsCuyQ.mjs} +0 -0
- /package/dist/{loadResources-DBMQg_Aj.mjs → loadResources-fol6JuvW.mjs} +0 -0
- /package/dist/{metrics-Qnvwc-LQ.mjs → metrics-B-PU4-Yu.mjs} +0 -0
- /package/dist/{replyHelpers-CK-FNO8E.mjs → replyHelpers-boS5Rx26.mjs} +0 -0
- /package/dist/{schemaIR-lYhC2gE5.mjs → schemaIR-DAOvBWgT.mjs} +0 -0
- /package/dist/{sessionManager-C4Le_UB3.d.mts → sessionManager-DlmaHUAl.d.mts} +0 -0
- /package/dist/{storage-Dfzt4VTl.d.mts → storage-HcAgP5Ni.d.mts} +0 -0
- /package/dist/{store-helpers-BkIN9-vu.mjs → store-helpers-5JgaxieH.mjs} +0 -0
- /package/dist/{tracing-QJVprktp.d.mts → tracing-bRqqUxFO.d.mts} +0 -0
- /package/dist/{versioning-BUrT5aP4.mjs → versioning-CuIwUQZ2.mjs} +0 -0
- /package/dist/{websocket-ChC2rqe1.d.mts → websocket-BJ0w-3Dt.d.mts} +0 -0
|
@@ -0,0 +1,582 @@
|
|
|
1
|
+
import { t as CRUD_OPERATIONS } from "./constants-TrJVIJl0.mjs";
|
|
2
|
+
import fp from "fastify-plugin";
|
|
3
|
+
//#region src/registry/assertNoTenantData.ts
|
|
4
|
+
/**
|
|
5
|
+
* Walk every cascading resource, run a tenant-scoped count, compare
|
|
6
|
+
* against the strategy's expected outcome.
|
|
7
|
+
*
|
|
8
|
+
* Designed for use inside compliance tests:
|
|
9
|
+
*
|
|
10
|
+
* ```ts
|
|
11
|
+
* import { assertNoTenantData } from '@classytic/arc/registry';
|
|
12
|
+
*
|
|
13
|
+
* it('after org delete, no tenant data leaks', async () => {
|
|
14
|
+
* await cascadeDeleteForOrganization(arc.registry, { organizationId: 'test-org' });
|
|
15
|
+
* const report = await assertNoTenantData(arc.registry, { organizationId: 'test-org' });
|
|
16
|
+
* expect(report.ok).toBe(true);
|
|
17
|
+
* expect(report.leaks).toHaveLength(0);
|
|
18
|
+
* });
|
|
19
|
+
* ```
|
|
20
|
+
*/
|
|
21
|
+
async function assertNoTenantData(registry, options) {
|
|
22
|
+
const { organizationId, only, skipAnonymize = true } = options;
|
|
23
|
+
if (!organizationId) throw new Error("assertNoTenantData: `organizationId` is required");
|
|
24
|
+
const onlySet = only ? new Set(only) : void 0;
|
|
25
|
+
const leaks = [];
|
|
26
|
+
const skipped = [];
|
|
27
|
+
let checked = 0;
|
|
28
|
+
for (const r of registry.getAll()) {
|
|
29
|
+
const resolved = r.resolvedTenantPurge;
|
|
30
|
+
if (!resolved || resolved.source === "disabled") continue;
|
|
31
|
+
if (onlySet && !onlySet.has(r.name)) continue;
|
|
32
|
+
const tenantField = typeof r.tenantField === "string" && r.tenantField || "organizationId";
|
|
33
|
+
const strategy = resolved.strategy;
|
|
34
|
+
if (strategy.type === "skip") {
|
|
35
|
+
skipped.push({
|
|
36
|
+
resource: r.name,
|
|
37
|
+
reason: strategy.reason
|
|
38
|
+
});
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
if (strategy.type === "anonymize" && skipAnonymize) {
|
|
42
|
+
skipped.push({
|
|
43
|
+
resource: r.name,
|
|
44
|
+
reason: "anonymize (rows legitimately retained)"
|
|
45
|
+
});
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
const repo = registry.getAdapter(r.name)?.repository;
|
|
49
|
+
if (!repo?.count) {
|
|
50
|
+
skipped.push({
|
|
51
|
+
resource: r.name,
|
|
52
|
+
reason: "adapter repository has no `count` method — cannot verify"
|
|
53
|
+
});
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
const actual = await repo.count({ [tenantField]: organizationId });
|
|
57
|
+
checked++;
|
|
58
|
+
const expected = 0;
|
|
59
|
+
if (actual !== expected) leaks.push({
|
|
60
|
+
resource: r.name,
|
|
61
|
+
tenantField,
|
|
62
|
+
strategy,
|
|
63
|
+
expected,
|
|
64
|
+
actual
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
return {
|
|
68
|
+
ok: leaks.length === 0,
|
|
69
|
+
organizationId,
|
|
70
|
+
checked,
|
|
71
|
+
skipped,
|
|
72
|
+
leaks
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
//#endregion
|
|
76
|
+
//#region src/registry/purgeResource.ts
|
|
77
|
+
/**
|
|
78
|
+
* Run the resolved strategy against one resource's repository.
|
|
79
|
+
*
|
|
80
|
+
* - `skip` strategy: returns `path: 'skipped'` without touching the repo.
|
|
81
|
+
* - `purgeByField` present: routes through it (chunked, progress, abort).
|
|
82
|
+
* - `purgeByField` absent + hard strategy: legacy `deleteMany` fallback.
|
|
83
|
+
* - `purgeByField` absent + non-hard strategy: returns `path: 'unsupported'`
|
|
84
|
+
* with a clear error — soft/anonymize require the new primitive.
|
|
85
|
+
*/
|
|
86
|
+
async function purgeResource(resourceName, tenantField, organizationId, resolved, repo, options = {}) {
|
|
87
|
+
const { strategy } = resolved;
|
|
88
|
+
if (strategy.type === "skip") return {
|
|
89
|
+
resource: resourceName,
|
|
90
|
+
tenantField,
|
|
91
|
+
strategy,
|
|
92
|
+
processed: 0,
|
|
93
|
+
ok: true,
|
|
94
|
+
path: "skipped",
|
|
95
|
+
skipReason: strategy.reason
|
|
96
|
+
};
|
|
97
|
+
if (typeof repo.purgeByField === "function") {
|
|
98
|
+
const result = await repo.purgeByField(tenantField, organizationId, strategy, {
|
|
99
|
+
batchSize: options.batchSize ?? resolved.batchSize,
|
|
100
|
+
onProgress: options.onProgress,
|
|
101
|
+
signal: options.signal
|
|
102
|
+
});
|
|
103
|
+
return {
|
|
104
|
+
resource: resourceName,
|
|
105
|
+
tenantField,
|
|
106
|
+
strategy,
|
|
107
|
+
processed: result.processed,
|
|
108
|
+
ok: result.ok,
|
|
109
|
+
path: "purgeByField",
|
|
110
|
+
durationMs: result.durationMs,
|
|
111
|
+
...result.error ? { error: result.error } : {}
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
if (strategy.type !== "hard") return {
|
|
115
|
+
resource: resourceName,
|
|
116
|
+
tenantField,
|
|
117
|
+
strategy,
|
|
118
|
+
processed: 0,
|
|
119
|
+
ok: false,
|
|
120
|
+
path: "unsupported",
|
|
121
|
+
error: {
|
|
122
|
+
code: "arc.purge.unsupported_strategy",
|
|
123
|
+
message: `Resource '${resourceName}' uses strategy '${strategy.type}' but its adapter repository does not implement \`purgeByField\`. Upgrade the adapter (@classytic/mongokit ≥ 3.13.4 / @classytic/sqlitekit ≥ 0.3.4) or change the strategy to 'hard'.`
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
const op = repo.deleteMany ?? repo.deleteByFilter ?? repo.removeMany;
|
|
127
|
+
if (!op) return {
|
|
128
|
+
resource: resourceName,
|
|
129
|
+
tenantField,
|
|
130
|
+
strategy,
|
|
131
|
+
processed: 0,
|
|
132
|
+
ok: false,
|
|
133
|
+
path: "unsupported",
|
|
134
|
+
error: {
|
|
135
|
+
code: "arc.purge.no_bulk_op",
|
|
136
|
+
message: `Resource '${resourceName}' adapter exposes neither \`purgeByField\` nor \`deleteMany\` / \`deleteByFilter\` / \`removeMany\` — bulk cleanup is not supported.`
|
|
137
|
+
}
|
|
138
|
+
};
|
|
139
|
+
try {
|
|
140
|
+
const start = Date.now();
|
|
141
|
+
const result = await op.call(repo, { [tenantField]: organizationId });
|
|
142
|
+
return {
|
|
143
|
+
resource: resourceName,
|
|
144
|
+
tenantField,
|
|
145
|
+
strategy,
|
|
146
|
+
processed: typeof result === "number" ? result : typeof result?.deletedCount === "number" ? result.deletedCount : typeof result?.count === "number" ? result.count : -1,
|
|
147
|
+
ok: true,
|
|
148
|
+
path: "legacy-deleteMany",
|
|
149
|
+
durationMs: Date.now() - start
|
|
150
|
+
};
|
|
151
|
+
} catch (err) {
|
|
152
|
+
return {
|
|
153
|
+
resource: resourceName,
|
|
154
|
+
tenantField,
|
|
155
|
+
strategy,
|
|
156
|
+
processed: 0,
|
|
157
|
+
ok: false,
|
|
158
|
+
path: "legacy-deleteMany",
|
|
159
|
+
error: {
|
|
160
|
+
code: err?.code,
|
|
161
|
+
message: err instanceof Error ? err.message : String(err)
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
//#endregion
|
|
167
|
+
//#region src/registry/cascadeOrgDelete.ts
|
|
168
|
+
/**
|
|
169
|
+
* Names of resources flagged for cascade — used by audit scripts.
|
|
170
|
+
* A resource is cascading when its resolved strategy isn't `disabled`
|
|
171
|
+
* (i.e. the host declared `onTenantDelete`).
|
|
172
|
+
*/
|
|
173
|
+
function getCascadingResources(registry) {
|
|
174
|
+
return registry.getAll().filter((r) => isResourceCascading(r)).map((r) => r.name);
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Rich introspection — returns the resolved strategy + source per
|
|
178
|
+
* cascading resource. Use for audit dashboards that answer "what
|
|
179
|
+
* happens to this resource on org-delete?" without grepping the
|
|
180
|
+
* source.
|
|
181
|
+
*
|
|
182
|
+
* **Returns the full `TenantPurgeStrategy` discriminated union**, not
|
|
183
|
+
* just its `type` tag. The `skip` variant carries a required `reason`
|
|
184
|
+
* field — auditors need it to sign off on every "we deliberately don't
|
|
185
|
+
* purge this" entry. Anonymize carries the field map. Custom carries
|
|
186
|
+
* the handler reference. Narrow on `.strategy.type` for typed access.
|
|
187
|
+
*/
|
|
188
|
+
function getCascadingResourcesWithMetadata(registry) {
|
|
189
|
+
return registry.getAll().filter((r) => isResourceCascading(r)).map((r) => {
|
|
190
|
+
const resolved = r.resolvedTenantPurge;
|
|
191
|
+
const tenantField = typeof r.tenantField === "string" && r.tenantField || "organizationId";
|
|
192
|
+
const r0 = resolved ?? {
|
|
193
|
+
strategy: { type: "hard" },
|
|
194
|
+
priority: 100,
|
|
195
|
+
source: "declared"
|
|
196
|
+
};
|
|
197
|
+
return {
|
|
198
|
+
name: r.name,
|
|
199
|
+
tenantField,
|
|
200
|
+
strategy: r0.strategy,
|
|
201
|
+
source: r0.source,
|
|
202
|
+
priority: r0.priority
|
|
203
|
+
};
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
/** A resource cascades iff its resolved strategy isn't `disabled`. */
|
|
207
|
+
function isResourceCascading(r) {
|
|
208
|
+
return r.resolvedTenantPurge ? r.resolvedTenantPurge.source !== "disabled" : false;
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* Cascade-cleanup every tenant-scoped resource for the given organization.
|
|
212
|
+
* Walks the registry in **ascending priority order** (`onTenantDelete.priority`
|
|
213
|
+
* — leaf data first, references last), runs each resource's resolved
|
|
214
|
+
* strategy via `purgeByField` when available (chunked, progress, abort),
|
|
215
|
+
* and returns a structured report.
|
|
216
|
+
*
|
|
217
|
+
* **Strategy resolution** lives in `resolveTenantPurge.ts` — this runner
|
|
218
|
+
* just reads `resource.resolvedTenantPurge` (computed once at boot).
|
|
219
|
+
*
|
|
220
|
+
* **Per-resource execution** lives in `purgeResource.ts` — preferred
|
|
221
|
+
* path is the kit's `purgeByField` (chunked + plugin-composed); falls
|
|
222
|
+
* back to legacy `deleteMany` only for `hard` strategy on adapters that
|
|
223
|
+
* haven't been upgraded.
|
|
224
|
+
*
|
|
225
|
+
* **Failure semantics**: continues on per-resource error, returns the
|
|
226
|
+
* full report. Hosts decide whether a partial cascade is a hard
|
|
227
|
+
* failure (re-throw) or degraded mode (log + alert).
|
|
228
|
+
*
|
|
229
|
+
* @param registry The arc resource registry (`fastify.arc.registry`).
|
|
230
|
+
* @param options Org id + filters + logger + progress + signal.
|
|
231
|
+
*/
|
|
232
|
+
async function cascadeDeleteForOrganization(registry, options) {
|
|
233
|
+
const { organizationId, skip, only, logger, onProgress, signal, batchSize, concurrency = 1, checkpoint } = options;
|
|
234
|
+
if (!organizationId) throw new Error("cascadeDeleteForOrganization: `organizationId` is required");
|
|
235
|
+
if (!Number.isInteger(concurrency) || concurrency < 1) throw new Error("cascadeDeleteForOrganization: `concurrency` must be a positive integer");
|
|
236
|
+
const skipSet = skip ? new Set(skip) : void 0;
|
|
237
|
+
const onlySet = only ? new Set(only) : void 0;
|
|
238
|
+
const start = Date.now();
|
|
239
|
+
const resumeState = await checkpoint?.read();
|
|
240
|
+
const completedSet = resumeState ? new Set(resumeState.completedResources) : void 0;
|
|
241
|
+
const flagged = registry.getAll().filter((r) => {
|
|
242
|
+
if (!r.resolvedTenantPurge || r.resolvedTenantPurge.source === "disabled") return false;
|
|
243
|
+
if (skipSet?.has(r.name)) return false;
|
|
244
|
+
if (onlySet && !onlySet.has(r.name)) return false;
|
|
245
|
+
if (completedSet?.has(r.name)) return false;
|
|
246
|
+
return true;
|
|
247
|
+
}).sort((a, b) => {
|
|
248
|
+
return (a.resolvedTenantPurge?.priority ?? 100) - (b.resolvedTenantPurge?.priority ?? 100);
|
|
249
|
+
});
|
|
250
|
+
const results = [];
|
|
251
|
+
const completed = resumeState ? [...resumeState.completedResources] : [];
|
|
252
|
+
const groups = groupByPriority(flagged);
|
|
253
|
+
for (const group of groups) {
|
|
254
|
+
if (signal?.aborted) break;
|
|
255
|
+
const groupResults = await runWithConcurrency(group, concurrency, async (r) => {
|
|
256
|
+
if (signal?.aborted) return;
|
|
257
|
+
return runOneResource(r, registry, organizationId, {
|
|
258
|
+
onProgress,
|
|
259
|
+
signal,
|
|
260
|
+
batchSize,
|
|
261
|
+
logger
|
|
262
|
+
});
|
|
263
|
+
});
|
|
264
|
+
for (const report of groupResults) {
|
|
265
|
+
if (!report) continue;
|
|
266
|
+
results.push(report);
|
|
267
|
+
if (!report.error) {
|
|
268
|
+
completed.push(report.resource);
|
|
269
|
+
if (checkpoint) await checkpoint.write({ completedResources: [...completed] });
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
const successes = results.filter((row) => !row.error);
|
|
274
|
+
return {
|
|
275
|
+
organizationId,
|
|
276
|
+
resources: results,
|
|
277
|
+
successes,
|
|
278
|
+
failures: results.filter((row) => row.error),
|
|
279
|
+
totalDeleted: successes.reduce((sum, r) => sum + (r.deletedCount > 0 ? r.deletedCount : 0), 0),
|
|
280
|
+
durationMs: Date.now() - start
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
/** Process a single registry entry under its resolved strategy. */
|
|
284
|
+
async function runOneResource(r, registry, organizationId, ctx) {
|
|
285
|
+
const tenantField = typeof r.tenantField === "string" && r.tenantField || "organizationId";
|
|
286
|
+
const repo = registry.getAdapter(r.name)?.repository;
|
|
287
|
+
if (!repo) {
|
|
288
|
+
const report = {
|
|
289
|
+
resource: r.name,
|
|
290
|
+
tenantField,
|
|
291
|
+
deletedCount: 0,
|
|
292
|
+
path: "unsupported",
|
|
293
|
+
error: {
|
|
294
|
+
code: "arc.no_adapter",
|
|
295
|
+
message: `Resource '${r.name}' has no adapter repository — cascade skipped`
|
|
296
|
+
}
|
|
297
|
+
};
|
|
298
|
+
ctx.logger?.warn?.(report, "[Arc/Cascade] resource skipped (no adapter)");
|
|
299
|
+
return report;
|
|
300
|
+
}
|
|
301
|
+
const resourceProgress = ctx.onProgress ? (event) => ctx.onProgress?.({
|
|
302
|
+
...event,
|
|
303
|
+
resource: r.name
|
|
304
|
+
}) : void 0;
|
|
305
|
+
const outcome = await purgeResource(r.name, tenantField, organizationId, r.resolvedTenantPurge ?? {
|
|
306
|
+
strategy: { type: "hard" },
|
|
307
|
+
priority: 100,
|
|
308
|
+
source: "declared"
|
|
309
|
+
}, repo, {
|
|
310
|
+
onProgress: resourceProgress,
|
|
311
|
+
signal: ctx.signal,
|
|
312
|
+
batchSize: ctx.batchSize
|
|
313
|
+
});
|
|
314
|
+
const report = {
|
|
315
|
+
resource: outcome.resource,
|
|
316
|
+
tenantField: outcome.tenantField,
|
|
317
|
+
deletedCount: outcome.processed,
|
|
318
|
+
strategy: outcome.strategy,
|
|
319
|
+
strategySource: r.resolvedTenantPurge?.source,
|
|
320
|
+
path: outcome.path,
|
|
321
|
+
...outcome.skipReason ? { skipReason: outcome.skipReason } : {},
|
|
322
|
+
...outcome.error ? { error: outcome.error } : {}
|
|
323
|
+
};
|
|
324
|
+
if (outcome.ok) ctx.logger?.info?.(report, "[Arc/Cascade] resource processed");
|
|
325
|
+
else ctx.logger?.error?.({ report }, "[Arc/Cascade] resource cascade failed");
|
|
326
|
+
return report;
|
|
327
|
+
}
|
|
328
|
+
/**
|
|
329
|
+
* Group a priority-sorted list of resources into priority barriers.
|
|
330
|
+
* Each returned sub-array contains resources of identical priority;
|
|
331
|
+
* the outer array preserves ascending order. The cascade runner uses
|
|
332
|
+
* this so all priority-N resources finish before any priority-(N+M)
|
|
333
|
+
* starts — leaf-before-references invariant survives concurrency.
|
|
334
|
+
*/
|
|
335
|
+
function groupByPriority(sorted) {
|
|
336
|
+
if (sorted.length === 0) return [];
|
|
337
|
+
const first = sorted[0];
|
|
338
|
+
if (!first) return [];
|
|
339
|
+
const groups = [];
|
|
340
|
+
let current = [first];
|
|
341
|
+
let currentPriority = first.resolvedTenantPurge?.priority ?? 100;
|
|
342
|
+
for (let i = 1; i < sorted.length; i++) {
|
|
343
|
+
const item = sorted[i];
|
|
344
|
+
if (!item) continue;
|
|
345
|
+
const p = item.resolvedTenantPurge?.priority ?? 100;
|
|
346
|
+
if (p === currentPriority) current.push(item);
|
|
347
|
+
else {
|
|
348
|
+
groups.push(current);
|
|
349
|
+
current = [item];
|
|
350
|
+
currentPriority = p;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
groups.push(current);
|
|
354
|
+
return groups;
|
|
355
|
+
}
|
|
356
|
+
/**
|
|
357
|
+
* Run `fn` over `items` with bounded concurrency. Order of results
|
|
358
|
+
* matches input order. Simpler shape than a worker-pool because the
|
|
359
|
+
* group sizes are small (typically <=12 resources per priority band).
|
|
360
|
+
*/
|
|
361
|
+
async function runWithConcurrency(items, concurrency, fn) {
|
|
362
|
+
if (concurrency === 1 || items.length <= 1) {
|
|
363
|
+
const results = [];
|
|
364
|
+
for (const item of items) results.push(await fn(item));
|
|
365
|
+
return results;
|
|
366
|
+
}
|
|
367
|
+
const results = new Array(items.length);
|
|
368
|
+
let cursor = 0;
|
|
369
|
+
const workers = Array.from({ length: Math.min(concurrency, items.length) }, async () => {
|
|
370
|
+
while (true) {
|
|
371
|
+
const i = cursor++;
|
|
372
|
+
if (i >= items.length) break;
|
|
373
|
+
const item = items[i];
|
|
374
|
+
if (item === void 0) continue;
|
|
375
|
+
results[i] = await fn(item);
|
|
376
|
+
}
|
|
377
|
+
});
|
|
378
|
+
await Promise.all(workers);
|
|
379
|
+
return results;
|
|
380
|
+
}
|
|
381
|
+
//#endregion
|
|
382
|
+
//#region src/registry/introspectionPlugin.ts
|
|
383
|
+
const introspectionPlugin = async (fastify, opts = {}) => {
|
|
384
|
+
const { prefix = "/_resources", authRoles = ["superadmin"], enabled = true } = opts;
|
|
385
|
+
if (!enabled) {
|
|
386
|
+
fastify.log?.debug?.("Introspection plugin disabled");
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
const typedFastify = fastify;
|
|
390
|
+
const authMiddleware = authRoles.length > 0 && typedFastify.authenticate ? [typedFastify.authenticate, typedFastify.authorize?.(...authRoles)].filter(Boolean) : [];
|
|
391
|
+
const getRegistry = () => fastify.arc?.registry;
|
|
392
|
+
await fastify.register(async (instance) => {
|
|
393
|
+
instance.get("/", { preHandler: authMiddleware }, async (_req, _reply) => {
|
|
394
|
+
return getRegistry()?.getIntrospection() ?? {
|
|
395
|
+
resources: [],
|
|
396
|
+
stats: {},
|
|
397
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
398
|
+
};
|
|
399
|
+
});
|
|
400
|
+
instance.get("/stats", { preHandler: authMiddleware }, async (_req, _reply) => {
|
|
401
|
+
return getRegistry()?.getStats() ?? {
|
|
402
|
+
totalResources: 0,
|
|
403
|
+
byModule: {},
|
|
404
|
+
presetUsage: {},
|
|
405
|
+
totalRoutes: 0,
|
|
406
|
+
totalEvents: 0
|
|
407
|
+
};
|
|
408
|
+
});
|
|
409
|
+
instance.get("/:name", {
|
|
410
|
+
schema: { params: {
|
|
411
|
+
type: "object",
|
|
412
|
+
properties: { name: { type: "string" } },
|
|
413
|
+
required: ["name"]
|
|
414
|
+
} },
|
|
415
|
+
preHandler: authMiddleware
|
|
416
|
+
}, async (req, reply) => {
|
|
417
|
+
const resource = getRegistry()?.get(req.params.name);
|
|
418
|
+
if (!resource) return reply.code(404).send({ error: `Resource '${req.params.name}' not found` });
|
|
419
|
+
return resource;
|
|
420
|
+
});
|
|
421
|
+
}, { prefix });
|
|
422
|
+
fastify.log?.debug?.(`Introspection API at ${prefix}`);
|
|
423
|
+
};
|
|
424
|
+
var introspectionPlugin_default = fp(introspectionPlugin, { name: "arc-introspection" });
|
|
425
|
+
//#endregion
|
|
426
|
+
//#region src/registry/manifest.ts
|
|
427
|
+
/**
|
|
428
|
+
* Resource manifest for frontend code generators (v2.15.5).
|
|
429
|
+
*
|
|
430
|
+
* Every host that hand-rolls a `createCrudApi('foo')` helper on the frontend
|
|
431
|
+
* ends up rewriting the same shim for declarative actions: `postAction(id,
|
|
432
|
+
* name, body)` — and forgets to add a method for every NEW action declared
|
|
433
|
+
* on the resource. The OpenAI-team report flagged this in fajr's
|
|
434
|
+
* `invoices.js`, and now matches needs the same.
|
|
435
|
+
*
|
|
436
|
+
* Arc owns the resource definition, so arc can ship the metadata FE codegen
|
|
437
|
+
* needs in one canonical shape:
|
|
438
|
+
*
|
|
439
|
+
* {
|
|
440
|
+
* name: 'invoice',
|
|
441
|
+
* prefix: '/invoices',
|
|
442
|
+
* idField: '_id',
|
|
443
|
+
* crudOps: ['list', 'get', 'create', 'update', 'delete'],
|
|
444
|
+
* actions: [
|
|
445
|
+
* { name: 'recordPayment', mount: '/:id/action', requiresId: true, description: '...' },
|
|
446
|
+
* { name: 'propose', mount: '/action', requiresId: false, description: '...' },
|
|
447
|
+
* ],
|
|
448
|
+
* }
|
|
449
|
+
*
|
|
450
|
+
* Hosts feed this into their FE generator (or a runtime `createActionsApi`
|
|
451
|
+
* shim) and every action is wired automatically. New actions land on the FE
|
|
452
|
+
* the same moment they're declared on the resource — no parallel list to
|
|
453
|
+
* keep in sync.
|
|
454
|
+
*
|
|
455
|
+
* The helper is BE-side only (lives under `@classytic/arc/registry`) — arc
|
|
456
|
+
* stays a backend framework and doesn't ship FE runtime code. The host
|
|
457
|
+
* decides whether the manifest powers a fetch-based helper, a TanStack
|
|
458
|
+
* Query factory, an RPC client, or static `.d.ts` generation.
|
|
459
|
+
*
|
|
460
|
+
* @example BE → emit JSON at build time / on a route
|
|
461
|
+
* ```ts
|
|
462
|
+
* // Build-time codegen
|
|
463
|
+
* import { buildResourceManifest } from '@classytic/arc/registry';
|
|
464
|
+
* import { invoiceResource } from './invoice.resource.js';
|
|
465
|
+
* writeFileSync('./fe-gen/invoice.manifest.json',
|
|
466
|
+
* JSON.stringify(buildResourceManifest(invoiceResource)));
|
|
467
|
+
*
|
|
468
|
+
* // Runtime introspection endpoint
|
|
469
|
+
* fastify.get('/manifest/:name', async (req) => {
|
|
470
|
+
* const r = arc.registry.get(req.params.name);
|
|
471
|
+
* return r ? buildResourceManifestFromRegistry(r) : reply.code(404);
|
|
472
|
+
* });
|
|
473
|
+
* ```
|
|
474
|
+
*
|
|
475
|
+
* @example FE → generate fetch methods from the manifest
|
|
476
|
+
* ```ts
|
|
477
|
+
* import manifest from './fe-gen/invoice.manifest.json';
|
|
478
|
+
* import { createActionsApi } from './my-fe-shim.js'; // host-owned
|
|
479
|
+
*
|
|
480
|
+
* export const invoicesApi = {
|
|
481
|
+
* ...createCrudApi(manifest.prefix),
|
|
482
|
+
* ...createActionsApi(manifest.prefix, manifest.actions),
|
|
483
|
+
* // → invoicesApi.recordPayment(id, body)
|
|
484
|
+
* // → invoicesApi.propose(body) // id-less
|
|
485
|
+
* };
|
|
486
|
+
* ```
|
|
487
|
+
*/
|
|
488
|
+
/**
|
|
489
|
+
* Build a manifest from a `ResourceDefinition` (the value returned by
|
|
490
|
+
* `defineResource(...)`). Use at build time when you have direct access
|
|
491
|
+
* to the resource module — typically the simplest codegen path.
|
|
492
|
+
*/
|
|
493
|
+
function buildResourceManifest(resource) {
|
|
494
|
+
const disabled = new Set(resource.disabledRoutes ?? []);
|
|
495
|
+
const crudOps = resource.disableDefaultRoutes ? [] : CRUD_OPERATIONS.filter((op) => !disabled.has(op));
|
|
496
|
+
const actions = [];
|
|
497
|
+
for (const [name, entry] of Object.entries(resource.actions ?? {})) {
|
|
498
|
+
const requiresId = typeof entry === "function" ? true : entry.id !== false;
|
|
499
|
+
actions.push({
|
|
500
|
+
name,
|
|
501
|
+
mount: requiresId ? "/:id/action" : "/action",
|
|
502
|
+
requiresId,
|
|
503
|
+
...typeof entry !== "function" && entry.description ? { description: entry.description } : {}
|
|
504
|
+
});
|
|
505
|
+
}
|
|
506
|
+
const aggregations = [];
|
|
507
|
+
for (const [name, agg] of Object.entries(resource.aggregations ?? {})) aggregations.push({
|
|
508
|
+
name,
|
|
509
|
+
path: `${resource.prefix}/aggregations/${name}`,
|
|
510
|
+
...agg.summary ? { summary: agg.summary } : {},
|
|
511
|
+
...agg.description ? { description: agg.description } : {}
|
|
512
|
+
});
|
|
513
|
+
const customRoutes = [];
|
|
514
|
+
for (const route of resource.routes ?? []) customRoutes.push({
|
|
515
|
+
method: route.method,
|
|
516
|
+
path: `${resource.prefix}${route.path}`,
|
|
517
|
+
...route.operation ? { operation: route.operation } : {},
|
|
518
|
+
...route.summary ? { summary: route.summary } : {}
|
|
519
|
+
});
|
|
520
|
+
return {
|
|
521
|
+
name: resource.name,
|
|
522
|
+
displayName: resource.displayName,
|
|
523
|
+
prefix: resource.prefix,
|
|
524
|
+
idField: resource.idField ?? "_id",
|
|
525
|
+
updateMethod: resource.updateMethod ?? "PATCH",
|
|
526
|
+
crudOps,
|
|
527
|
+
actions,
|
|
528
|
+
aggregations,
|
|
529
|
+
customRoutes,
|
|
530
|
+
...resource.tenantField !== void 0 ? { tenantField: resource.tenantField } : {}
|
|
531
|
+
};
|
|
532
|
+
}
|
|
533
|
+
/**
|
|
534
|
+
* Build a manifest from a `RegistryEntry` — use when the resource is
|
|
535
|
+
* already registered (introspection endpoint, runtime audit script).
|
|
536
|
+
*
|
|
537
|
+
* Equivalent to {@link buildResourceManifest} but reads from the projected
|
|
538
|
+
* registry shape, so a host that doesn't have the original `defineResource`
|
|
539
|
+
* value in scope (e.g. an FE-gen sidecar that scrapes a running server's
|
|
540
|
+
* `/_resources` endpoint) gets the same output without dragging the resource
|
|
541
|
+
* module into the codegen path.
|
|
542
|
+
*/
|
|
543
|
+
function buildResourceManifestFromRegistry(entry) {
|
|
544
|
+
const disabled = new Set(entry.disabledRoutes ?? []);
|
|
545
|
+
const crudOps = entry.disableDefaultRoutes ? [] : CRUD_OPERATIONS.filter((op) => !disabled.has(op));
|
|
546
|
+
const actions = (entry.actions ?? []).map((a) => {
|
|
547
|
+
const requiresId = a.id !== false;
|
|
548
|
+
return {
|
|
549
|
+
name: a.name,
|
|
550
|
+
mount: requiresId ? "/:id/action" : "/action",
|
|
551
|
+
requiresId,
|
|
552
|
+
...a.description ? { description: a.description } : {}
|
|
553
|
+
};
|
|
554
|
+
});
|
|
555
|
+
const aggregations = (entry.aggregations ?? []).map((agg) => ({
|
|
556
|
+
name: agg.name,
|
|
557
|
+
path: `${entry.prefix}/aggregations/${agg.name}`,
|
|
558
|
+
...agg.summary ? { summary: agg.summary } : {},
|
|
559
|
+
...agg.description ? { description: agg.description } : {}
|
|
560
|
+
}));
|
|
561
|
+
const customRoutes = (entry.customRoutes ?? []).map((r) => ({
|
|
562
|
+
method: r.method,
|
|
563
|
+
path: `${entry.prefix}${r.path}`,
|
|
564
|
+
...r.operation ? { operation: r.operation } : {},
|
|
565
|
+
...r.summary ? { summary: r.summary } : {}
|
|
566
|
+
}));
|
|
567
|
+
const updateMethod = entry.updateMethod ?? "PATCH";
|
|
568
|
+
return {
|
|
569
|
+
name: entry.name,
|
|
570
|
+
displayName: entry.displayName ?? entry.name,
|
|
571
|
+
prefix: entry.prefix,
|
|
572
|
+
idField: "_id",
|
|
573
|
+
updateMethod,
|
|
574
|
+
crudOps,
|
|
575
|
+
actions,
|
|
576
|
+
aggregations,
|
|
577
|
+
customRoutes,
|
|
578
|
+
...entry.tenantField !== void 0 ? { tenantField: entry.tenantField } : {}
|
|
579
|
+
};
|
|
580
|
+
}
|
|
581
|
+
//#endregion
|
|
582
|
+
export { cascadeDeleteForOrganization as a, assertNoTenantData as c, introspectionPlugin_default as i, buildResourceManifestFromRegistry as n, getCascadingResources as o, introspectionPlugin as r, getCascadingResourcesWithMetadata as s, buildResourceManifest as t };
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { f as createError } from "./errors-j4aJm1Wg.mjs";
|
|
2
|
-
import { b as
|
|
2
|
+
import { b as isElevated, c as getOrgId, g as getUserId, n as PUBLIC_SCOPE, x as isMember } from "./types-Bi0r0vjG.mjs";
|
|
3
3
|
import { t as requestContext } from "./requestContext-SSaaTgW8.mjs";
|
|
4
|
-
import { I as evaluateAndApplyPermission, p as resolveEffectiveRoles, u as applyFieldReadPermissions } from "./permissions-
|
|
4
|
+
import { I as evaluateAndApplyPermission, p as resolveEffectiveRoles, u as applyFieldReadPermissions } from "./permissions-CTxMrreC.mjs";
|
|
5
5
|
import { t as getUserRoles } from "./types-D57iXYb8.mjs";
|
|
6
|
-
import { t as executePipeline } from "./pipe-
|
|
6
|
+
import { t as executePipeline } from "./pipe-DiCyvyPN.mjs";
|
|
7
7
|
import { isPaginatedResult, toCanonicalList } from "@classytic/repo-core/pagination";
|
|
8
8
|
//#region src/scope/projection.ts
|
|
9
9
|
/**
|
|
@@ -55,9 +55,13 @@ function applyFieldMask(data, fieldMask) {
|
|
|
55
55
|
return data;
|
|
56
56
|
}
|
|
57
57
|
/**
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
58
|
+
* Project `FastifyRequest` into the curated `IRequestContext` shape that
|
|
59
|
+
* arc's controllers receive. This is a developer-facing facade, not a
|
|
60
|
+
* portability boundary — arc is Fastify-only. The projection lifts the
|
|
61
|
+
* fields tenant-scoped controllers reach for (`organizationId`,
|
|
62
|
+
* `teamId`, `scope`, `context`, server accessor) onto a flat, typed
|
|
63
|
+
* surface so handler code doesn't have to dig through Fastify's
|
|
64
|
+
* decorator surface or `req.metadata._scope` internals.
|
|
61
65
|
*/
|
|
62
66
|
function createRequestContext(req) {
|
|
63
67
|
const reqWithExtras = req;
|
|
@@ -145,12 +149,33 @@ function computeFieldCapabilities(fieldPerms, effectiveRoles) {
|
|
|
145
149
|
return caps;
|
|
146
150
|
}
|
|
147
151
|
/**
|
|
148
|
-
* Send IControllerResponse
|
|
152
|
+
* Send a controller's `IControllerResponse` through Fastify's reply.
|
|
153
|
+
*
|
|
154
|
+
* The `IControllerResponse<T>` envelope is arc's internal pipeline
|
|
155
|
+
* contract — it's how custom `status`, `meta`, `headers`, and field
|
|
156
|
+
* permissions thread from a handler through interceptors to the wire
|
|
157
|
+
* without each step having to touch `reply` directly. Not a portability
|
|
158
|
+
* abstraction (arc is Fastify-only); a pipeline-internal one.
|
|
149
159
|
*
|
|
150
|
-
*
|
|
151
|
-
*
|
|
160
|
+
* **Auto-envelope of bare returns.** Custom route handlers are
|
|
161
|
+
* expected to return the envelope shape so this function can apply
|
|
162
|
+
* field permissions, attach `fieldCaps`, and flatten paginated lists.
|
|
163
|
+
* A handler that returns raw `T` (array, plain object, primitive) used
|
|
164
|
+
* to silently produce a broken response — `response.data` was
|
|
165
|
+
* `undefined`, the body sent was `undefined`, and any declared
|
|
166
|
+
* `fields:` permission map was bypassed (the leak shape mentora
|
|
167
|
+
* flagged). 2.17 detects bare returns and wraps them into
|
|
168
|
+
* `{ data: response, status: 200 }` before the field-permission
|
|
169
|
+
* pipeline runs, so `fields:` applies to ALL custom routes whether the
|
|
170
|
+
* handler envelopes its return value or not. The envelope shape is
|
|
171
|
+
* still the canonical contract — auto-wrapping is a safety net, not
|
|
172
|
+
* an invitation to drop the envelope.
|
|
152
173
|
*/
|
|
153
174
|
function sendControllerResponse(reply, response, request) {
|
|
175
|
+
if (response === null || typeof response !== "object" || !Object.hasOwn(response, "data")) response = {
|
|
176
|
+
data: response,
|
|
177
|
+
status: 200
|
|
178
|
+
};
|
|
154
179
|
const reqWithExtras = request;
|
|
155
180
|
const fieldMaskConfig = reqWithExtras?.fieldMask;
|
|
156
181
|
const arcMeta = reqWithExtras?.arc;
|
|
@@ -207,18 +232,17 @@ function sendControllerResponse(reply, response, request) {
|
|
|
207
232
|
reply.code(status).send(filteredData);
|
|
208
233
|
}
|
|
209
234
|
/**
|
|
210
|
-
*
|
|
211
|
-
*
|
|
212
|
-
*
|
|
235
|
+
* Bridge an `IController` method (`(ctx: IRequestContext) => Promise<IControllerResponse<T>>`)
|
|
236
|
+
* into a Fastify route handler. Translates between arc's curated request
|
|
237
|
+
* context surface and Fastify's request/reply, and routes the envelope
|
|
238
|
+
* through `sendControllerResponse` so the pipeline applies field
|
|
239
|
+
* permissions, status, and meta uniformly. Internal plumbing, not a
|
|
240
|
+
* portability layer — arc is Fastify-only.
|
|
213
241
|
*
|
|
214
242
|
* @example
|
|
215
243
|
* ```typescript
|
|
216
244
|
* const controller = new BaseController(repository);
|
|
217
|
-
*
|
|
218
|
-
* // Create Fastify handler
|
|
219
245
|
* const listHandler = createFastifyHandler(controller.list.bind(controller));
|
|
220
|
-
*
|
|
221
|
-
* // Register route
|
|
222
246
|
* fastify.get('/products', listHandler);
|
|
223
247
|
* ```
|
|
224
248
|
*/
|
package/dist/scope/index.d.mts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { C as
|
|
2
|
-
import { i as elevationPlugin, n as ElevationOptions, r as _default, t as ElevationEvent } from "../elevation-
|
|
1
|
+
import { C as isMember, D as requireOrgId, E as requireClientId, O as requireTeamId, S as isElevated, T as isService, _ as getTenantFromRequest, a as getAncestorOrgIds, b as hasOrgAccess, c as getMandate, d as getOrgRoles, f as getRequestScope, g as getTeamId, h as getServiceScopes, i as RequestScope, k as requireUserId, l as getOrgContext, m as getScopeContextMap, n as Mandate, o as getClientId, p as getScopeContext, r as PUBLIC_SCOPE, s as getDPoPJkt, t as AUTHENTICATED_SCOPE, u as getOrgId, v as getUserId, w as isOrgInScope, x as isAuthenticated, y as getUserRoles } from "../types-CfapW2t8.mjs";
|
|
2
|
+
import { i as elevationPlugin, n as ElevationOptions, r as _default, t as ElevationEvent } from "../elevation-DvwtjDwu.mjs";
|
|
3
3
|
import { FastifyReply, FastifyRequest } from "fastify";
|
|
4
4
|
|
|
5
5
|
//#region src/scope/rateLimitKey.d.ts
|
|
@@ -30,4 +30,4 @@ interface ResolveOrgFromHeaderOptions {
|
|
|
30
30
|
*/
|
|
31
31
|
declare function resolveOrgFromHeader(options: ResolveOrgFromHeaderOptions): (request: FastifyRequest, reply: FastifyReply) => Promise<void>;
|
|
32
32
|
//#endregion
|
|
33
|
-
export { AUTHENTICATED_SCOPE, type ElevationEvent, type ElevationOptions, type Mandate, PUBLIC_SCOPE, type RateLimitKeyContext, type RequestScope, type ResolveOrgFromHeaderOptions, type TenantKeyGeneratorOptions, createTenantKeyGenerator, _default as elevationPlugin, elevationPlugin as elevationPluginFn, getAncestorOrgIds, getClientId, getDPoPJkt, getMandate, getOrgContext, getOrgId, getOrgRoles, getRequestScope, getScopeContext, getScopeContextMap, getServiceScopes, getTeamId, getUserId, getUserRoles, hasOrgAccess, isAuthenticated, isElevated, isMember, isOrgInScope, isService, requireClientId, requireOrgId, requireTeamId, requireUserId, resolveOrgFromHeader };
|
|
33
|
+
export { AUTHENTICATED_SCOPE, type ElevationEvent, type ElevationOptions, type Mandate, PUBLIC_SCOPE, type RateLimitKeyContext, type RequestScope, type ResolveOrgFromHeaderOptions, type TenantKeyGeneratorOptions, createTenantKeyGenerator, _default as elevationPlugin, elevationPlugin as elevationPluginFn, getAncestorOrgIds, getClientId, getDPoPJkt, getMandate, getOrgContext, getOrgId, getOrgRoles, getRequestScope, getScopeContext, getScopeContextMap, getServiceScopes, getTeamId, getTenantFromRequest, getUserId, getUserRoles, hasOrgAccess, isAuthenticated, isElevated, isMember, isOrgInScope, isService, requireClientId, requireOrgId, requireTeamId, requireUserId, resolveOrgFromHeader };
|