@classytic/arc 2.21.0 → 2.23.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/{BaseController-BnLYgE9A.mjs → BaseController-BQ8KNvWC.mjs} +3 -2
- package/dist/{ResourceRegistry-7RnrEWNP.mjs → ResourceRegistry-y9Hnypus.mjs} +1 -0
- package/dist/{actionPermissions-ze70xXJi.mjs → actionPermissions-lszoJle1.mjs} +1 -1
- package/dist/applyPermissionResult-Tw4iXtlf.mjs +264 -0
- package/dist/audit/index.d.mts +2 -2
- package/dist/auth/index.d.mts +30 -2
- package/dist/auth/index.mjs +6 -3
- package/dist/cli/commands/describe.d.mts +1 -1
- package/dist/cli/commands/describe.mjs +1 -1
- package/dist/cli/commands/docs.mjs +2 -2
- package/dist/cli/commands/init.mjs +1 -1
- package/dist/cli/commands/introspect.mjs +1 -1
- package/dist/cli/index.mjs +1 -1
- package/dist/core/index.d.mts +2 -2
- package/dist/core/index.mjs +3 -3
- package/dist/{core-DZ6Xe1jR.mjs → core-DQ6rDTyH.mjs} +126 -68
- package/dist/{createActionRouter-BWMDW-dL.mjs → createActionRouter-BuOv-m4Y.mjs} +10 -6
- package/dist/{createAggregationRouter-l5mNdYrZ.mjs → createAggregationRouter-CgJv_BU_.mjs} +3 -3
- package/dist/{createApp-Bi61XxqU.mjs → createApp-CjOeWJ9_.mjs} +252 -57
- package/dist/docs/index.d.mts +1 -1
- package/dist/docs/index.mjs +1 -1
- package/dist/encryption/index.mjs +9 -5
- package/dist/{errorHandler-Dxf6P_kV.mjs → errorHandler-Bq2OeEQL.mjs} +34 -10
- package/dist/{errorHandler-B0e-bq-O.d.mts → errorHandler-C6yv4ZTW.d.mts} +13 -0
- package/dist/events/index.mjs +5 -2
- package/dist/factory/index.d.mts +46 -9
- package/dist/factory/index.mjs +64 -13
- package/dist/{fastify-augmentation-DafigGQp.d.mts → fastify-augmentation-g_f9omSc.d.mts} +2 -2
- package/dist/fields-D3lhgzhD.d.mts +121 -0
- package/dist/health-ScyjIVo5.mjs +202 -0
- package/dist/hooks/index.d.mts +1 -1
- package/dist/idempotency/index.d.mts +7 -0
- package/dist/idempotency/index.mjs +7 -17
- package/dist/{index-Dtrj6XZk.d.mts → index-BBsvQt7U.d.mts} +1310 -1226
- package/dist/{types-DpxoNX5v.d.mts → index-Ba10fdKp.d.mts} +196 -66
- package/dist/{index-B5N-NaHs.d.mts → index-CC5ruDGh.d.mts} +3 -3
- package/dist/{index-B5PoNbfu.d.mts → index-wMoWBLkn.d.mts} +68 -2
- package/dist/index.d.mts +6 -5
- package/dist/index.mjs +6 -5
- package/dist/{init-0YhHkXGk.mjs → init-CjO4p_q1.mjs} +123 -4
- package/dist/integrations/event-gateway.mjs +1 -1
- package/dist/integrations/index.d.mts +1 -1
- package/dist/integrations/mcp/index.d.mts +67 -4
- package/dist/integrations/mcp/index.mjs +1 -1
- package/dist/integrations/mcp/testing.d.mts +1 -1
- package/dist/integrations/mcp/testing.mjs +1 -1
- package/dist/integrations/webhooks.d.mts +131 -7
- package/dist/integrations/webhooks.mjs +169 -10
- package/dist/interface-n5hpKg1d.mjs +16 -0
- package/dist/jsonBody-BlzAoGCe.mjs +43 -0
- package/dist/{loadResourcesFromEntry-OMCvIEE7.mjs → loadResourcesFromEntry-kvGT6EcO.mjs} +1 -1
- package/dist/logger/index.d.mts +26 -1
- package/dist/logger/index.mjs +39 -1
- package/dist/{mcpPlugin-B9XjjNVG.mjs → mcpPlugin-BtzIL_kM.mjs} +312 -39
- package/dist/middleware/index.d.mts +1 -1
- package/dist/{module-WeuU5jfd.d.mts → module-B4u10iOJ.d.mts} +26 -1
- package/dist/{openapi-Kyod_di6.mjs → openapi-jSDx-VDv.mjs} +3 -3
- package/dist/permissions/index.d.mts +4 -3
- package/dist/permissions/index.mjs +3 -2
- package/dist/{permissions-BJMuKMl7.mjs → permissions-tbxFHxjZ.mjs} +77 -264
- package/dist/pipeline/index.d.mts +1 -1
- package/dist/plugins/index.d.mts +24 -7
- package/dist/plugins/index.mjs +14 -282
- package/dist/plugins/response-cache.mjs +1 -0
- package/dist/plugins/tracing-entry.mjs +7 -4
- package/dist/presets/filesUpload.d.mts +2 -2
- package/dist/presets/filesUpload.mjs +1 -1
- 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/search.d.mts +2 -2
- package/dist/presets/search.mjs +1 -1
- package/dist/{presets-gw0ciIUD.mjs → presets-BOe4Ugkl.mjs} +1 -1
- package/dist/rateLimitKey-BoWRH3e7.mjs +14 -0
- package/dist/realtime-I8OrfnMR.mjs +375 -0
- package/dist/{registerRoute-oGAl0oA8.mjs → registerRoute-CWO6mdYe.mjs} +38 -18
- package/dist/registry/index.d.mts +2 -2
- package/dist/registry/index.mjs +1 -1
- package/dist/requestId-5BhTVzBv.mjs +113 -0
- package/dist/schemaConverter-DJvodiHj.mjs +171 -0
- package/dist/{schemaIR-ajetw3Fv.mjs → schemaIR-Ca7ySNPx.mjs} +2 -2
- package/dist/scim/index.mjs +6 -7
- package/dist/scope/index.mjs +2 -76
- package/dist/scope-LONQ3mfZ.mjs +37 -0
- package/dist/simpleEqualityMatcher-Cg05HPsn.mjs +68 -0
- package/dist/{sse-CfUjwjDk.mjs → sse-0u4P4-OI.mjs} +7 -30
- package/dist/sseStream-BQ7Qaynp.mjs +57 -0
- package/dist/testing/index.d.mts +83 -3
- package/dist/testing/index.mjs +70 -3
- package/dist/types/index.d.mts +2 -2
- package/dist/{types-DRj-Ut4n.d.mts → types-5OuqeTDX.d.mts} +37 -2
- package/dist/{fields-BGqrAa_u.d.mts → types-Csxga7d3.d.mts} +1 -121
- package/dist/usage/index.d.mts +142 -0
- package/dist/usage/index.mjs +0 -0
- package/dist/utils/index.d.mts +101 -19
- package/dist/utils/index.mjs +5 -3
- package/dist/{utils-CO8a3B_1.mjs → utils-CZyeRXTh.mjs} +80 -82
- package/dist/{versioning-DQ4mCvg0.d.mts → versioning-D6PvCwpq.d.mts} +43 -9
- package/llms.txt +39 -0
- package/package.json +513 -447
- package/skills/arc/SKILL.md +14 -8
- package/skills/arc/references/api-reference.md +34 -0
- package/skills/arc/references/auth.md +21 -4
- package/skills/arc/references/mcp.md +1 -1
- package/skills/arc/references/production.md +734 -654
- package/skills/arc-code-review/SKILL.md +1 -1
- package/skills/arc-code-review/references/anti-patterns.md +71 -18
- package/skills/arc-code-review/references/arc-cheatsheet.md +15 -2
- package/skills/arc-code-review/references/migration-recipes.md +7 -7
- package/skills/arc-code-review/references/mongokit-migration.md +4 -3
- package/skills/arc-module-publishing/SKILL.md +2 -1
- package/skills/arc-module-publishing/references/authoring-modules.md +55 -0
- package/skills/arc-module-publishing/references/package-manifest.md +4 -2
- package/dist/preloadResources-CHw3_oA_.mjs +0 -45
- package/dist/schemaConverter-U0MnO9vd.mjs +0 -110
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
import { t as __exportAll } from "./rolldown-runtime-8H4AJuhK.mjs";
|
|
2
|
+
import { arcLog } from "./logger/index.mjs";
|
|
3
|
+
import { c as getOrgId } from "./types-bHblZ9YN.mjs";
|
|
4
|
+
import { t as simpleEqualityMatcher } from "./simpleEqualityMatcher-Cg05HPsn.mjs";
|
|
5
|
+
import { i as applyFieldReadPermissions, n as evaluateAndApplyPermission } from "./applyPermissionResult-Tw4iXtlf.mjs";
|
|
6
|
+
import { t as getUserRoles } from "./types-XTApa8b6.mjs";
|
|
7
|
+
import { a as promoteStreamTokenToHeader } from "./streaming-BQcW6_Kq.mjs";
|
|
8
|
+
import { t as openSseStream } from "./sseStream-BQ7Qaynp.mjs";
|
|
9
|
+
import fp from "fastify-plugin";
|
|
10
|
+
//#region src/plugins/realtime.ts
|
|
11
|
+
var realtime_exports = /* @__PURE__ */ __exportAll({
|
|
12
|
+
buildChangeFrame: () => buildChangeFrame,
|
|
13
|
+
connectionDeadlineMs: () => connectionDeadlineMs,
|
|
14
|
+
default: () => realtime_default,
|
|
15
|
+
realtimePlugin: () => realtimePlugin,
|
|
16
|
+
resolveDelivery: () => resolveDelivery,
|
|
17
|
+
resolveRowMatcher: () => resolveRowMatcher
|
|
18
|
+
});
|
|
19
|
+
const log = arcLog("realtime");
|
|
20
|
+
const CHANGE_OPERATIONS = [
|
|
21
|
+
"created",
|
|
22
|
+
"updated",
|
|
23
|
+
"deleted"
|
|
24
|
+
];
|
|
25
|
+
/**
|
|
26
|
+
* SSE `retry:` reconnection hint (ms). Deliberate: the feed has no replay,
|
|
27
|
+
* so a reconnecting client refetches the list anyway — a short retry keeps
|
|
28
|
+
* the gap small without hammering the server.
|
|
29
|
+
*/
|
|
30
|
+
const DEFAULT_RETRY_MS = 3e3;
|
|
31
|
+
/**
|
|
32
|
+
* Cap on resources per multiplexed connection. Generous for real
|
|
33
|
+
* dashboards (Supabase caps channels-per-client similarly); prevents a
|
|
34
|
+
* single socket from fanning out unbounded subscription work.
|
|
35
|
+
*/
|
|
36
|
+
const MAX_MULTIPLEX_RESOURCES = 20;
|
|
37
|
+
/** Node's `setTimeout` ceiling (~24.8 days); larger delays fire immediately. */
|
|
38
|
+
const MAX_TIMER_MS = 2147483647;
|
|
39
|
+
/**
|
|
40
|
+
* Milliseconds from `now` until the feed should force-close to bound
|
|
41
|
+
* permission staleness — `min(auth-token exp, maxConnectionMs)`. Returns
|
|
42
|
+
* `undefined` when neither bound applies (no `exp` claim, no cap → the feed
|
|
43
|
+
* lives until client disconnect), `0` when already expired, else the
|
|
44
|
+
* (setTimeout-ceiling-clamped) delay. Pure; exported for tests.
|
|
45
|
+
*/
|
|
46
|
+
function connectionDeadlineMs(user, maxConnectionMs, now = Date.now()) {
|
|
47
|
+
let deadline;
|
|
48
|
+
const exp = user?.exp;
|
|
49
|
+
if (typeof exp === "number" && Number.isFinite(exp)) deadline = exp * 1e3 - now;
|
|
50
|
+
if (maxConnectionMs !== void 0 && maxConnectionMs > 0) deadline = deadline === void 0 ? maxConnectionMs : Math.min(deadline, maxConnectionMs);
|
|
51
|
+
if (deadline === void 0) return void 0;
|
|
52
|
+
if (deadline <= 0) return 0;
|
|
53
|
+
return Math.min(deadline, MAX_TIMER_MS);
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Are these filters flat equality (every value a scalar, no `$` operators)?
|
|
57
|
+
* That is the shape `simpleEqualityMatcher` is safe for — anything richer
|
|
58
|
+
* needs the adapter's own matcher.
|
|
59
|
+
*/
|
|
60
|
+
function isFlatEquality(filters) {
|
|
61
|
+
for (const [key, value] of Object.entries(filters)) {
|
|
62
|
+
if (key.startsWith("$")) return false;
|
|
63
|
+
if (value !== null && typeof value === "object" && !(value instanceof Date)) return false;
|
|
64
|
+
}
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Decide how (or whether) this subscription's row filters can be enforced
|
|
69
|
+
* in process: adapter matcher when supplied, `simpleEqualityMatcher` for
|
|
70
|
+
* flat-equality shapes, UNENFORCEABLE (fail-closed → 501) for
|
|
71
|
+
* operator-shaped filters with no adapter matcher. No filters → nothing
|
|
72
|
+
* to enforce.
|
|
73
|
+
*/
|
|
74
|
+
function resolveRowMatcher(filters, adapter) {
|
|
75
|
+
const hasFilters = !!filters && Object.keys(filters).length > 0;
|
|
76
|
+
if (adapter?.matchesFilter) {
|
|
77
|
+
const matcher = adapter.matchesFilter.bind(adapter);
|
|
78
|
+
if (hasFilters && filters) try {
|
|
79
|
+
matcher({}, filters);
|
|
80
|
+
} catch {
|
|
81
|
+
return { enforceable: false };
|
|
82
|
+
}
|
|
83
|
+
return {
|
|
84
|
+
enforceable: true,
|
|
85
|
+
matcher
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
if (!hasFilters) return { enforceable: true };
|
|
89
|
+
if (isFlatEquality(filters)) return {
|
|
90
|
+
enforceable: true,
|
|
91
|
+
matcher: simpleEqualityMatcher
|
|
92
|
+
};
|
|
93
|
+
return { enforceable: false };
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Decide this event's fate for one subscriber. Pure — the whole
|
|
97
|
+
* authorization + membership-transition story lives here:
|
|
98
|
+
*
|
|
99
|
+
* 1. tenant guard: org-carrying events only reach matching-org subscribers
|
|
100
|
+
* (a cross-org record was never visible → drop, never a leave).
|
|
101
|
+
* 2. row filters (connect-time snapshot):
|
|
102
|
+
* - matches (or no filters) → deliver.
|
|
103
|
+
* - no match + operation is `updated` → LEAVE (membership
|
|
104
|
+
* transition out; the record may have been in the subscriber's view).
|
|
105
|
+
* - no match + operation is `created`/`deleted` → drop (never entered
|
|
106
|
+
* the view — a created record that doesn't match was never shown; a
|
|
107
|
+
* deleted record that doesn't match was already filtered out).
|
|
108
|
+
*
|
|
109
|
+
* The ENTER transition (a record updated so it now DOES match) needs no
|
|
110
|
+
* special signal: the matching `updated` frame carries the full record, so
|
|
111
|
+
* the client adds it if absent.
|
|
112
|
+
*/
|
|
113
|
+
function resolveDelivery(event, policy, operation) {
|
|
114
|
+
if (policy.tenantScoped) {
|
|
115
|
+
const eventOrgId = event.meta?.organizationId;
|
|
116
|
+
if (eventOrgId && eventOrgId !== policy.subscriberOrgId) return { kind: "drop" };
|
|
117
|
+
}
|
|
118
|
+
const doc = event.payload?.data;
|
|
119
|
+
if (!(!!policy.filters && Object.keys(policy.filters).length > 0)) return {
|
|
120
|
+
kind: "deliver",
|
|
121
|
+
doc
|
|
122
|
+
};
|
|
123
|
+
if (!!doc && typeof doc === "object" && !!policy.matcher?.(doc, policy.filters)) return {
|
|
124
|
+
kind: "deliver",
|
|
125
|
+
doc
|
|
126
|
+
};
|
|
127
|
+
return operation === "updated" ? { kind: "leave" } : { kind: "drop" };
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Build the wire frame for one delivered change. Envelope note: this is
|
|
131
|
+
* deliberately DOCUMENT-CHANGE-shaped (`data` = the masked document),
|
|
132
|
+
* unlike the sse plugin's raw event mirror (`payload` = whatever was
|
|
133
|
+
* published) — the two streams serve different consumers and the
|
|
134
|
+
* divergence is intentional, not drift.
|
|
135
|
+
*/
|
|
136
|
+
function buildChangeFrame(event, resourceName, data) {
|
|
137
|
+
return JSON.stringify({
|
|
138
|
+
type: event.type,
|
|
139
|
+
resource: resourceName,
|
|
140
|
+
id: event.meta?.resourceId,
|
|
141
|
+
data,
|
|
142
|
+
meta: {
|
|
143
|
+
timestamp: event.meta?.timestamp,
|
|
144
|
+
correlationId: event.meta?.correlationId
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
const realtimePlugin = async (fastify, opts = {}) => {
|
|
149
|
+
const { path = "/realtime/:resource", heartbeat = 3e4, resources: allowlist, operations = CHANGE_OPERATIONS, tokenQueryParam = "token", maxConnectionMs } = opts;
|
|
150
|
+
if (!fastify.hasDecorator("events")) {
|
|
151
|
+
log.warn("Events plugin (arc-events) not registered. Realtime plugin will not function. Register eventPlugin before realtimePlugin.");
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
if (!path.includes(":resource")) throw new Error(`[arc-realtime] path must contain ':resource' (got '${path}')`);
|
|
155
|
+
const activeConnections = /* @__PURE__ */ new Set();
|
|
156
|
+
const isProtected = (entry) => {
|
|
157
|
+
const listCheck = entry.permissions?.list;
|
|
158
|
+
return typeof listCheck === "function" && listCheck._isPublic !== true;
|
|
159
|
+
};
|
|
160
|
+
/**
|
|
161
|
+
* Run auth once for a set of requested resources: `authenticate` when ANY
|
|
162
|
+
* is protected (401 on missing/invalid token), `optionalAuthenticate`
|
|
163
|
+
* otherwise so tokens still parse. Returns false when a reply was sent.
|
|
164
|
+
*/
|
|
165
|
+
const runAuth = async (request, reply, entries) => {
|
|
166
|
+
const needsAuth = entries.some(isProtected);
|
|
167
|
+
if (tokenQueryParam) promoteStreamTokenToHeader(request, tokenQueryParam);
|
|
168
|
+
const authenticate = needsAuth ? fastify.authenticate : fastify.optionalAuthenticate;
|
|
169
|
+
if (needsAuth && typeof authenticate !== "function") {
|
|
170
|
+
await reply.code(401).send({
|
|
171
|
+
code: "arc.unauthorized",
|
|
172
|
+
message: "Authentication required",
|
|
173
|
+
status: 401
|
|
174
|
+
});
|
|
175
|
+
return false;
|
|
176
|
+
}
|
|
177
|
+
if (typeof authenticate === "function") {
|
|
178
|
+
await authenticate(request, reply);
|
|
179
|
+
if (reply.sent) return false;
|
|
180
|
+
}
|
|
181
|
+
return true;
|
|
182
|
+
};
|
|
183
|
+
/**
|
|
184
|
+
* Authorize ONE resource for this connection: run its list permission,
|
|
185
|
+
* snapshot its row filters IN ISOLATION (evaluateAndApplyPermission
|
|
186
|
+
* accumulates onto request._policyFilters — on a multiplexed connection
|
|
187
|
+
* resource B must never inherit resource A's filters, so the field is
|
|
188
|
+
* saved/reset/restored around each evaluation), and resolve the row
|
|
189
|
+
* matcher fail-closed. Sends the denial/501 reply itself; returns null
|
|
190
|
+
* when the caller should stop.
|
|
191
|
+
*/
|
|
192
|
+
const authorizeResource = async (resourceName, entry, request, reply) => {
|
|
193
|
+
const holder = request;
|
|
194
|
+
const saved = holder._policyFilters;
|
|
195
|
+
holder._policyFilters = void 0;
|
|
196
|
+
try {
|
|
197
|
+
const listCheck = entry.permissions?.list;
|
|
198
|
+
if (typeof listCheck === "function") {
|
|
199
|
+
if (!await evaluateAndApplyPermission(listCheck, {
|
|
200
|
+
user: request.user ?? null,
|
|
201
|
+
request,
|
|
202
|
+
resource: resourceName,
|
|
203
|
+
action: "list"
|
|
204
|
+
}, request, reply)) return null;
|
|
205
|
+
}
|
|
206
|
+
const filters = holder._policyFilters;
|
|
207
|
+
const resolution = resolveRowMatcher(filters, fastify.arc?.registry?.getAdapter(resourceName));
|
|
208
|
+
if (!resolution.enforceable) {
|
|
209
|
+
await reply.code(501).send({
|
|
210
|
+
code: "arc.realtime.unfilterable",
|
|
211
|
+
message: `The '${resourceName}' list permission returned operator-shaped row filters, which this feed cannot enforce in-process. Supply \`matchesFilter\` on the resource's DataAdapter to enable realtime for filter-scoped subscribers.`,
|
|
212
|
+
status: 501
|
|
213
|
+
});
|
|
214
|
+
return null;
|
|
215
|
+
}
|
|
216
|
+
const scope = request.scope;
|
|
217
|
+
return {
|
|
218
|
+
name: resourceName,
|
|
219
|
+
policy: {
|
|
220
|
+
tenantScoped: entry.tenantField !== false,
|
|
221
|
+
subscriberOrgId: scope ? getOrgId(scope) : void 0,
|
|
222
|
+
filters,
|
|
223
|
+
matcher: resolution.matcher
|
|
224
|
+
},
|
|
225
|
+
fields: entry.fields
|
|
226
|
+
};
|
|
227
|
+
} finally {
|
|
228
|
+
holder._policyFilters = saved;
|
|
229
|
+
}
|
|
230
|
+
};
|
|
231
|
+
/** Open the stream and wire every authorized subscription onto it. */
|
|
232
|
+
const sharedFrames = /* @__PURE__ */ new WeakMap();
|
|
233
|
+
const streamSubscriptions = async (request, reply, subscriptions) => {
|
|
234
|
+
const userRoles = getUserRoles(request.user ?? {});
|
|
235
|
+
const stream = openSseStream(request, reply, {
|
|
236
|
+
heartbeatMs: heartbeat,
|
|
237
|
+
retryMs: DEFAULT_RETRY_MS
|
|
238
|
+
});
|
|
239
|
+
stream.onCleanup(() => activeConnections.delete(stream.close));
|
|
240
|
+
activeConnections.add(stream.close);
|
|
241
|
+
const closeAt = connectionDeadlineMs(request.user, maxConnectionMs);
|
|
242
|
+
if (closeAt !== void 0) {
|
|
243
|
+
if (closeAt <= 0) {
|
|
244
|
+
stream.close();
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
const timer = setTimeout(stream.close, closeAt);
|
|
248
|
+
timer.unref?.();
|
|
249
|
+
stream.onCleanup(() => clearTimeout(timer));
|
|
250
|
+
}
|
|
251
|
+
for (const sub of subscriptions) {
|
|
252
|
+
const deliver = (event, operation) => {
|
|
253
|
+
const decision = resolveDelivery(event, sub.policy, operation);
|
|
254
|
+
if (decision.kind === "drop") return;
|
|
255
|
+
if (decision.kind === "leave") {
|
|
256
|
+
stream.write(`${sub.name}.left`, JSON.stringify({
|
|
257
|
+
type: `${sub.name}.left`,
|
|
258
|
+
resource: sub.name,
|
|
259
|
+
id: event.meta?.resourceId
|
|
260
|
+
}), event.meta?.id);
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
const { doc } = decision;
|
|
264
|
+
const needsMask = !!sub.fields && !!doc && typeof doc === "object";
|
|
265
|
+
let frame;
|
|
266
|
+
if (needsMask) {
|
|
267
|
+
const masked = applyFieldReadPermissions(doc, sub.fields, userRoles);
|
|
268
|
+
frame = buildChangeFrame(event, sub.name, masked);
|
|
269
|
+
} else {
|
|
270
|
+
const cached = sharedFrames.get(event);
|
|
271
|
+
frame = cached ?? buildChangeFrame(event, sub.name, doc);
|
|
272
|
+
if (!cached) sharedFrames.set(event, frame);
|
|
273
|
+
}
|
|
274
|
+
stream.write(event.type, frame, event.meta?.id);
|
|
275
|
+
};
|
|
276
|
+
for (const operation of operations) {
|
|
277
|
+
const unsub = await fastify.events.subscribe(`${sub.name}.${operation}`, (event) => deliver(event, operation));
|
|
278
|
+
stream.onCleanup(unsub);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
stream.write("ready", JSON.stringify({
|
|
282
|
+
resources: subscriptions.map((s) => s.name),
|
|
283
|
+
operations: [...operations]
|
|
284
|
+
}));
|
|
285
|
+
};
|
|
286
|
+
const lookupEntry = (name) => {
|
|
287
|
+
if (allowlist && !allowlist.includes(name)) return void 0;
|
|
288
|
+
return fastify.arc?.registry?.get(name);
|
|
289
|
+
};
|
|
290
|
+
const notFound = (reply, name) => reply.code(404).send({
|
|
291
|
+
code: "arc.not_found",
|
|
292
|
+
message: `Resource '${name}' has no realtime feed`,
|
|
293
|
+
status: 404
|
|
294
|
+
});
|
|
295
|
+
fastify.route({
|
|
296
|
+
method: "GET",
|
|
297
|
+
url: path,
|
|
298
|
+
schema: {
|
|
299
|
+
tags: ["Events"],
|
|
300
|
+
summary: "Realtime resource change feed",
|
|
301
|
+
description: "SSE stream of created/updated/deleted events for one resource, gated by its list permission — row filters and field-level read permissions apply per event.",
|
|
302
|
+
response: { 200: {
|
|
303
|
+
type: "string",
|
|
304
|
+
description: "text/event-stream"
|
|
305
|
+
} }
|
|
306
|
+
},
|
|
307
|
+
handler: async (request, reply) => {
|
|
308
|
+
const resourceName = request.params.resource;
|
|
309
|
+
const entry = lookupEntry(resourceName);
|
|
310
|
+
if (!entry) return notFound(reply, resourceName);
|
|
311
|
+
if (!await runAuth(request, reply, [entry])) return;
|
|
312
|
+
const sub = await authorizeResource(resourceName, entry, request, reply);
|
|
313
|
+
if (!sub) return;
|
|
314
|
+
await streamSubscriptions(request, reply, [sub]);
|
|
315
|
+
}
|
|
316
|
+
});
|
|
317
|
+
const multiplexPath = path.replace(/\/:resource$/, "");
|
|
318
|
+
fastify.route({
|
|
319
|
+
method: "GET",
|
|
320
|
+
url: multiplexPath,
|
|
321
|
+
schema: {
|
|
322
|
+
tags: ["Events"],
|
|
323
|
+
summary: "Realtime multi-resource change feed",
|
|
324
|
+
description: "Multiplexed SSE stream for several resources over one connection (?resources=a,b,c). Every resource is gated by its own list permission; any denial rejects the subscription.",
|
|
325
|
+
querystring: {
|
|
326
|
+
type: "object",
|
|
327
|
+
properties: { resources: { type: "string" } },
|
|
328
|
+
required: ["resources"]
|
|
329
|
+
},
|
|
330
|
+
response: { 200: {
|
|
331
|
+
type: "string",
|
|
332
|
+
description: "text/event-stream"
|
|
333
|
+
} }
|
|
334
|
+
},
|
|
335
|
+
handler: async (request, reply) => {
|
|
336
|
+
const raw = request.query.resources;
|
|
337
|
+
const names = [...new Set(raw.split(",").map((s) => s.trim()).filter(Boolean))];
|
|
338
|
+
if (names.length === 0 || names.length > MAX_MULTIPLEX_RESOURCES) return reply.code(400).send({
|
|
339
|
+
code: "arc.bad_request",
|
|
340
|
+
message: `resources must name 1–${MAX_MULTIPLEX_RESOURCES} registered resources`,
|
|
341
|
+
status: 400
|
|
342
|
+
});
|
|
343
|
+
const entries = [];
|
|
344
|
+
for (const name of names) {
|
|
345
|
+
const entry = lookupEntry(name);
|
|
346
|
+
if (!entry) return notFound(reply, name);
|
|
347
|
+
entries.push(entry);
|
|
348
|
+
}
|
|
349
|
+
if (!await runAuth(request, reply, entries)) return;
|
|
350
|
+
const subscriptions = [];
|
|
351
|
+
for (let i = 0; i < names.length; i++) {
|
|
352
|
+
const sub = await authorizeResource(names[i], entries[i], request, reply);
|
|
353
|
+
if (!sub) return;
|
|
354
|
+
subscriptions.push(sub);
|
|
355
|
+
}
|
|
356
|
+
await streamSubscriptions(request, reply, subscriptions);
|
|
357
|
+
}
|
|
358
|
+
});
|
|
359
|
+
fastify.addHook("onClose", async () => {
|
|
360
|
+
for (const close of activeConnections) close();
|
|
361
|
+
activeConnections.clear();
|
|
362
|
+
});
|
|
363
|
+
log.debug("Plugin registered", {
|
|
364
|
+
path,
|
|
365
|
+
operations,
|
|
366
|
+
allowlist
|
|
367
|
+
});
|
|
368
|
+
};
|
|
369
|
+
var realtime_default = fp(realtimePlugin, {
|
|
370
|
+
name: "arc-realtime",
|
|
371
|
+
fastify: "5.x",
|
|
372
|
+
dependencies: ["arc-events"]
|
|
373
|
+
});
|
|
374
|
+
//#endregion
|
|
375
|
+
export { realtime_default as n, realtime_exports as r, realtimePlugin as t };
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { f as createError } from "./errors-DkO8Nk6-.mjs";
|
|
2
2
|
import { n as requestContext } from "./requestContext-BZKOr13H.mjs";
|
|
3
|
-
import {
|
|
3
|
+
import { n as evaluateAndApplyPermission } from "./applyPermissionResult-Tw4iXtlf.mjs";
|
|
4
4
|
//#region src/core/middlewares/arcDecorator.ts
|
|
5
5
|
/**
|
|
6
6
|
* Build the `arcDecorator` preHandler for a resource.
|
|
@@ -81,24 +81,44 @@ function buildAuthMiddlewareForPermissions(fastify, permissions) {
|
|
|
81
81
|
//#endregion
|
|
82
82
|
//#region src/core/middlewares/chain.ts
|
|
83
83
|
/**
|
|
84
|
-
* Compose
|
|
85
|
-
*
|
|
86
|
-
* ingredients differently (regression risk:
|
|
87
|
-
* cache keys leak across users
|
|
84
|
+
* Compose the route-level `onRequest` + `preHandler` arrays in the canonical
|
|
85
|
+
* order. Every null/undefined entry is dropped. Keeps the three routers from
|
|
86
|
+
* accidentally ordering the same ingredients differently (regression risk:
|
|
87
|
+
* cache before auth → user-scoped cache keys leak across users; auth after
|
|
88
|
+
* body parse → anonymous schema probing).
|
|
88
89
|
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
90
|
+
* onRequest constraints (Fastify lifecycle): `request.body` is undefined —
|
|
91
|
+
* everything placed there must be header/cookie/param/query-driven. arc's
|
|
92
|
+
* `authenticate` / `optionalAuthenticate` qualify by contract (Bearer
|
|
93
|
+
* header, session cookie, api-key header); `preAuth` handlers exist
|
|
94
|
+
* precisely to promote query tokens into headers (SSE `?token=`); the arc
|
|
95
|
+
* decorator stamps static route metadata.
|
|
91
96
|
*/
|
|
92
|
-
function
|
|
93
|
-
return
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
97
|
+
function buildRouteHooks(parts) {
|
|
98
|
+
return {
|
|
99
|
+
onRequest: [
|
|
100
|
+
...parts.preAuth ?? [],
|
|
101
|
+
parts.arcDecorator,
|
|
102
|
+
parts.authMw ?? null
|
|
103
|
+
].filter(Boolean),
|
|
104
|
+
preHandler: [
|
|
105
|
+
parts.permissionMw ?? null,
|
|
106
|
+
parts.pluginMw ?? null,
|
|
107
|
+
...parts.routeGuards ?? [],
|
|
108
|
+
...parts.customMws ?? []
|
|
109
|
+
].filter(Boolean)
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Spread helper — turns `RouteHookChains` into the two Fastify route-option
|
|
114
|
+
* fields, omitting empty arrays so routes without middleware stay
|
|
115
|
+
* option-free (Fastify skips absent hooks faster than empty arrays).
|
|
116
|
+
*/
|
|
117
|
+
function routeHookOptions(hooks) {
|
|
118
|
+
return {
|
|
119
|
+
...hooks.onRequest.length > 0 ? { onRequest: hooks.onRequest } : {},
|
|
120
|
+
...hooks.preHandler.length > 0 ? { preHandler: hooks.preHandler } : {}
|
|
121
|
+
};
|
|
102
122
|
}
|
|
103
123
|
/**
|
|
104
124
|
* `RouteDefinition.preHandler` accepts two shapes:
|
|
@@ -329,4 +349,4 @@ function tryRegisterRoute(fastify, opts, context) {
|
|
|
329
349
|
}
|
|
330
350
|
}
|
|
331
351
|
//#endregion
|
|
332
|
-
export { selectPluginMw as a,
|
|
352
|
+
export { selectPluginMw as a, buildRouteHooks as c, buildAuthMiddleware as d, buildAuthMiddlewareForPermissions as f, resolveRouterPluginMw as i, resolveRoutePreHandlers as l, buildRateLimitConfig as n, buildActionPermissionMw as o, buildArcDecorator as p, buildRouteConfig as r, buildCrudPermissionMw as s, tryRegisterRoute as t, routeHookOptions as u };
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import "../fastify-augmentation-
|
|
1
|
+
import { K as ResolvedTenantPurge, R as RegisterOptions, V as ResourceDefinition, X as CrudRouteKey, k as IntrospectionPluginOptions, p as RegistryEntry, z as ResourceRegistry } from "../index-BBsvQt7U.mjs";
|
|
2
|
+
import "../fastify-augmentation-g_f9omSc.mjs";
|
|
3
3
|
import { FastifyPluginAsync } from "fastify";
|
|
4
4
|
import { TenantPurgeProgress, TenantPurgeStrategy } from "@classytic/repo-core/repository";
|
|
5
5
|
//#region src/registry/assertNoTenantData.d.ts
|
package/dist/registry/index.mjs
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
import { a as cascadeDeleteForOrganization, c as assertNoTenantData, i as introspectionPlugin_default, n as buildResourceManifestFromRegistry, o as getCascadingResources, r as introspectionPlugin, s as getCascadingResourcesWithMetadata, t as buildResourceManifest } from "../registry-vcOCtPij.mjs";
|
|
2
|
-
import { t as ResourceRegistry } from "../ResourceRegistry-
|
|
2
|
+
import { t as ResourceRegistry } from "../ResourceRegistry-y9Hnypus.mjs";
|
|
3
3
|
export { ResourceRegistry, assertNoTenantData, buildResourceManifest, buildResourceManifestFromRegistry, cascadeDeleteForOrganization, getCascadingResources, getCascadingResourcesWithMetadata, introspectionPlugin_default as introspectionPlugin, introspectionPlugin as introspectionPluginFn };
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import fp from "fastify-plugin";
|
|
3
|
+
//#region src/plugins/requestId.ts
|
|
4
|
+
/**
|
|
5
|
+
* Request ID Plugin
|
|
6
|
+
*
|
|
7
|
+
* Propagates request IDs for distributed tracing.
|
|
8
|
+
* - Accepts incoming x-request-id header (sanitized)
|
|
9
|
+
* - Generates UUID if not provided
|
|
10
|
+
* - Echoes the id on the response header + W3C trace-context propagation
|
|
11
|
+
*
|
|
12
|
+
* ID RESOLUTION LIVES AT THE SERVER LEVEL, NOT HERE. Fastify binds
|
|
13
|
+
* `request.log = logger.child({ reqId: request.id })` at request
|
|
14
|
+
* construction — before any hook runs — so an id assigned inside a hook
|
|
15
|
+
* never reaches the logs. `createApp` therefore wires
|
|
16
|
+
* {@link createRequestIdGenerator} into Fastify's `genReqId` option, and
|
|
17
|
+
* this plugin simply adopts `request.id`. Standalone registration on a
|
|
18
|
+
* bare Fastify instance falls back to the legacy in-hook resolution
|
|
19
|
+
* (header echo / UUID), with the documented limitation that
|
|
20
|
+
* `request.log`'s `reqId` binding predates the overwrite — pass
|
|
21
|
+
* `genReqId: createRequestIdGenerator()` to `Fastify()` to fix that.
|
|
22
|
+
*
|
|
23
|
+
* @example
|
|
24
|
+
* import { requestIdPlugin, createRequestIdGenerator } from '@classytic/arc';
|
|
25
|
+
*
|
|
26
|
+
* const fastify = Fastify({ genReqId: createRequestIdGenerator() });
|
|
27
|
+
* await fastify.register(requestIdPlugin);
|
|
28
|
+
*
|
|
29
|
+
* fastify.get('/', async (request) => {
|
|
30
|
+
* request.log.info('traced'); // carries the same reqId as the header
|
|
31
|
+
* return { id: request.id };
|
|
32
|
+
* });
|
|
33
|
+
*/
|
|
34
|
+
/**
|
|
35
|
+
* Sanitize an incoming request-id header value: max 128 chars, alphanumeric
|
|
36
|
+
* plus dashes/underscores/dots/colons only. Returns `undefined` for anything
|
|
37
|
+
* else (crafted values could pollute logs or response headers). Array values
|
|
38
|
+
* (repeated headers) are rejected outright — a client sending multiple
|
|
39
|
+
* request-id headers is not tracing in good faith.
|
|
40
|
+
*/
|
|
41
|
+
function sanitizeRequestId(value) {
|
|
42
|
+
if (typeof value !== "string") return void 0;
|
|
43
|
+
const trimmed = value.trim();
|
|
44
|
+
return trimmed.length > 0 && trimmed.length <= 128 && /^[\w.:-]+$/.test(trimmed) ? trimmed : void 0;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Build a Fastify `genReqId` function: adopt a sanitized incoming request-id
|
|
48
|
+
* header, or generate one. Wire this into the `Fastify()` constructor so
|
|
49
|
+
* `request.id`, `request.log`'s `reqId` binding, and the echoed response
|
|
50
|
+
* header all agree — `createApp` does this automatically.
|
|
51
|
+
*/
|
|
52
|
+
function createRequestIdGenerator(opts = {}) {
|
|
53
|
+
const { header = "x-request-id", generator = randomUUID } = opts;
|
|
54
|
+
const headerName = header.toLowerCase();
|
|
55
|
+
return (req) => sanitizeRequestId(req.headers[headerName]) ?? generator();
|
|
56
|
+
}
|
|
57
|
+
/** Fastify's default request ids are `req-<base36 counter>`. */
|
|
58
|
+
const FASTIFY_DEFAULT_ID = /^req-[0-9a-z]+$/;
|
|
59
|
+
/** W3C traceparent format: `version-traceid-parentid-flags` */
|
|
60
|
+
const TRACEPARENT_RE = /^([0-9a-f]{2})-([0-9a-f]{32})-([0-9a-f]{16})-([0-9a-f]{2})$/i;
|
|
61
|
+
/** W3C spec: version ff is reserved and invalid; trace/parent IDs must not be all zeros */
|
|
62
|
+
const TRACEID_ZEROS = "0".repeat(32);
|
|
63
|
+
const PARENTID_ZEROS = "0".repeat(16);
|
|
64
|
+
/** tracestate: printable ASCII, <= 512 bytes */
|
|
65
|
+
const TRACESTATE_MAX = 512;
|
|
66
|
+
function isValidTraceparent(tp) {
|
|
67
|
+
const m = TRACEPARENT_RE.exec(tp);
|
|
68
|
+
if (!m) return false;
|
|
69
|
+
const version = m[1] ?? "";
|
|
70
|
+
const traceId = m[2] ?? "";
|
|
71
|
+
const parentId = m[3] ?? "";
|
|
72
|
+
if (version.toLowerCase() === "ff") return false;
|
|
73
|
+
if (traceId.toLowerCase() === TRACEID_ZEROS) return false;
|
|
74
|
+
if (parentId.toLowerCase() === PARENTID_ZEROS) return false;
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
const requestIdPlugin = async (fastify, opts = {}) => {
|
|
78
|
+
const { header = "x-request-id", generator = randomUUID, setResponseHeader = true, propagateTraceContext = true } = opts;
|
|
79
|
+
if (!fastify.hasRequestDecorator("requestId")) fastify.decorateRequest("requestId", "");
|
|
80
|
+
if (!fastify.hasRequestDecorator("traceContext")) fastify.decorateRequest("traceContext", void 0);
|
|
81
|
+
fastify.addHook("onRequest", async (request, reply) => {
|
|
82
|
+
const incoming = sanitizeRequestId(request.headers[header]);
|
|
83
|
+
let requestId = String(request.id);
|
|
84
|
+
if (incoming && incoming !== requestId) requestId = incoming;
|
|
85
|
+
else if (!incoming && FASTIFY_DEFAULT_ID.test(requestId)) requestId = generator();
|
|
86
|
+
if (requestId !== request.id) request.id = requestId;
|
|
87
|
+
request.requestId = requestId;
|
|
88
|
+
if (setResponseHeader) reply.header(header, requestId);
|
|
89
|
+
if (propagateTraceContext) {
|
|
90
|
+
const rawTraceparent = request.headers["traceparent"];
|
|
91
|
+
const rawTracestate = request.headers["tracestate"];
|
|
92
|
+
if (typeof rawTraceparent === "string") {
|
|
93
|
+
const tp = rawTraceparent.trim();
|
|
94
|
+
if (isValidTraceparent(tp)) {
|
|
95
|
+
const ts = typeof rawTracestate === "string" && rawTracestate.length > 0 && rawTracestate.length <= TRACESTATE_MAX ? rawTracestate : void 0;
|
|
96
|
+
request.traceContext = {
|
|
97
|
+
traceparent: tp,
|
|
98
|
+
...ts ? { tracestate: ts } : {}
|
|
99
|
+
};
|
|
100
|
+
reply.header("traceparent", tp);
|
|
101
|
+
if (ts) reply.header("tracestate", ts);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
fastify.log?.debug?.("Request ID plugin registered");
|
|
107
|
+
};
|
|
108
|
+
var requestId_default = fp(requestIdPlugin, {
|
|
109
|
+
name: "arc-request-id",
|
|
110
|
+
fastify: "5.x"
|
|
111
|
+
});
|
|
112
|
+
//#endregion
|
|
113
|
+
export { sanitizeRequestId as i, requestIdPlugin as n, requestId_default as r, createRequestIdGenerator as t };
|