@clovnet/plugin-sdk 0.1.4
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/LICENSE +21 -0
- package/README.md +114 -0
- package/dist/chunk-XRSKWL3A.js +1872 -0
- package/dist/chunk-XRSKWL3A.js.map +1 -0
- package/dist/index.d.ts +3658 -0
- package/dist/index.js +561 -0
- package/dist/index.js.map +1 -0
- package/dist/testing.d.ts +275 -0
- package/dist/testing.js +1010 -0
- package/dist/testing.js.map +1 -0
- package/package.json +75 -0
package/dist/testing.js
ADDED
|
@@ -0,0 +1,1010 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CATALOG_ALLOWED_KINDS,
|
|
3
|
+
CATALOG_IMPORT_TASK_TYPE,
|
|
4
|
+
DataScopes,
|
|
5
|
+
PLUGIN_CATALOG_LIMITS,
|
|
6
|
+
PLUGIN_DATASET_LIMITS,
|
|
7
|
+
PLUGIN_JOB_LIMITS,
|
|
8
|
+
PLUGIN_MIGRATION_LIMITS,
|
|
9
|
+
ReadModelRequiredScopes,
|
|
10
|
+
deriveActionSchemas,
|
|
11
|
+
generateClientPackage,
|
|
12
|
+
jsonSchemaToTsType,
|
|
13
|
+
pascalCase,
|
|
14
|
+
validateActionDecls
|
|
15
|
+
} from "./chunk-XRSKWL3A.js";
|
|
16
|
+
|
|
17
|
+
// src/testing/context.ts
|
|
18
|
+
import { createHmac } from "crypto";
|
|
19
|
+
|
|
20
|
+
// src/testing/errors.ts
|
|
21
|
+
var TestContextError = class extends Error {
|
|
22
|
+
statusCode;
|
|
23
|
+
code;
|
|
24
|
+
details;
|
|
25
|
+
constructor(name, message, opts) {
|
|
26
|
+
super(message);
|
|
27
|
+
this.name = name;
|
|
28
|
+
this.statusCode = opts.statusCode;
|
|
29
|
+
this.code = opts.code;
|
|
30
|
+
this.details = opts.details;
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
var forbidden = (message) => new TestContextError("ForbiddenError", message, { statusCode: 403, code: "FORBIDDEN" });
|
|
34
|
+
var validation = (message, details) => new TestContextError("ValidationError", message, {
|
|
35
|
+
statusCode: 400,
|
|
36
|
+
code: "VALIDATION_ERROR",
|
|
37
|
+
details
|
|
38
|
+
});
|
|
39
|
+
var notFound = (message) => new TestContextError("NotFoundError", message, { statusCode: 404, code: "NOT_FOUND" });
|
|
40
|
+
var unauthorized = (message) => new TestContextError("UnauthorizedError", message, { statusCode: 401, code: "UNAUTHORIZED" });
|
|
41
|
+
var quotaExceeded = (message, details) => new TestContextError("PluginDatasetQuotaExceededError", message, {
|
|
42
|
+
statusCode: 409,
|
|
43
|
+
code: "PLUGIN_DATASET_QUOTA_EXCEEDED",
|
|
44
|
+
details
|
|
45
|
+
});
|
|
46
|
+
var egressDenied = (message) => new TestContextError("PluginEgressDeniedError", message, {
|
|
47
|
+
statusCode: 403,
|
|
48
|
+
code: "PLUGIN_EGRESS_DENIED"
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
// src/testing/catalog.ts
|
|
52
|
+
function catalogGrantedFor(manifest) {
|
|
53
|
+
return manifest.catalog !== void 0 && CATALOG_ALLOWED_KINDS.includes(manifest.kind);
|
|
54
|
+
}
|
|
55
|
+
var emptyLite = () => ({
|
|
56
|
+
providers: { inserted: 0, updated: 0 },
|
|
57
|
+
categories: { inserted: 0, updated: 0 },
|
|
58
|
+
restrictionGroups: { inserted: 0, updated: 0 },
|
|
59
|
+
currencyGroups: { inserted: 0, updated: 0 },
|
|
60
|
+
games: { inserted: 0, updated: 0, unchanged: 0 },
|
|
61
|
+
gameCategoryLinks: 0
|
|
62
|
+
});
|
|
63
|
+
var same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
|
64
|
+
function createCatalogMock(manifest, requestImport) {
|
|
65
|
+
const stores = {
|
|
66
|
+
providers: /* @__PURE__ */ new Map(),
|
|
67
|
+
categories: /* @__PURE__ */ new Map(),
|
|
68
|
+
restrictionGroups: /* @__PURE__ */ new Map(),
|
|
69
|
+
currencyGroups: /* @__PURE__ */ new Map(),
|
|
70
|
+
games: /* @__PURE__ */ new Map()
|
|
71
|
+
};
|
|
72
|
+
const capFamily = (family, rows) => {
|
|
73
|
+
if (rows && rows.length > PLUGIN_CATALOG_LIMITS.maxRowsPerFamily) {
|
|
74
|
+
throw validation(
|
|
75
|
+
`delta family '${family}' has ${rows.length} rows; the cap is ${PLUGIN_CATALOG_LIMITS.maxRowsPerFamily} per call`
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
const upsertInto = (store, key, value, counts) => {
|
|
80
|
+
const existing = store.get(key);
|
|
81
|
+
if (existing === void 0) {
|
|
82
|
+
store.set(key, value);
|
|
83
|
+
counts.inserted++;
|
|
84
|
+
} else if (!same(existing, value)) {
|
|
85
|
+
store.set(key, value);
|
|
86
|
+
counts.updated++;
|
|
87
|
+
} else if (counts.unchanged !== void 0) {
|
|
88
|
+
counts.unchanged++;
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
const stubProvider = (externalId, counts) => {
|
|
92
|
+
if (stores.providers.has(externalId)) return;
|
|
93
|
+
stores.providers.set(externalId, {
|
|
94
|
+
externalId,
|
|
95
|
+
name: `Provider ${externalId}`,
|
|
96
|
+
stub: true
|
|
97
|
+
});
|
|
98
|
+
counts.inserted++;
|
|
99
|
+
};
|
|
100
|
+
const applyDelta = (delta) => {
|
|
101
|
+
for (const family of [
|
|
102
|
+
"providers",
|
|
103
|
+
"categories",
|
|
104
|
+
"restrictionGroups",
|
|
105
|
+
"currencyGroups",
|
|
106
|
+
"games",
|
|
107
|
+
"gameCategories"
|
|
108
|
+
]) {
|
|
109
|
+
capFamily(family, delta[family]);
|
|
110
|
+
}
|
|
111
|
+
const summary = emptyLite();
|
|
112
|
+
for (const p of delta.providers ?? []) {
|
|
113
|
+
upsertInto(stores.providers, p.externalId, p, summary.providers);
|
|
114
|
+
}
|
|
115
|
+
for (const r of delta.restrictionGroups ?? []) {
|
|
116
|
+
upsertInto(stores.restrictionGroups, r.externalId, r, summary.restrictionGroups);
|
|
117
|
+
}
|
|
118
|
+
for (const c of delta.currencyGroups ?? []) {
|
|
119
|
+
upsertInto(stores.currencyGroups, c.externalId, c, summary.currencyGroups);
|
|
120
|
+
}
|
|
121
|
+
for (const c of delta.categories ?? []) {
|
|
122
|
+
upsertInto(stores.categories, c.externalId, c, summary.categories);
|
|
123
|
+
}
|
|
124
|
+
for (const g of delta.games ?? []) {
|
|
125
|
+
stubProvider(g.providerExternalId, summary.providers);
|
|
126
|
+
if (g.subProviderExternalId) stubProvider(g.subProviderExternalId, summary.providers);
|
|
127
|
+
if (g.restrictionGroupExternalId && !stores.restrictionGroups.has(g.restrictionGroupExternalId)) {
|
|
128
|
+
throw validation(
|
|
129
|
+
`game '${g.externalGameId}' references unknown restriction group externalId '${g.restrictionGroupExternalId}'`
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
if (g.currencyGroupExternalId && !stores.currencyGroups.has(g.currencyGroupExternalId)) {
|
|
133
|
+
throw validation(
|
|
134
|
+
`game '${g.externalGameId}' references unknown currency group externalId '${g.currencyGroupExternalId}'`
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
for (const ext of g.categoryExternalIds) {
|
|
138
|
+
if (!stores.categories.has(ext)) {
|
|
139
|
+
throw validation(
|
|
140
|
+
`game '${g.externalGameId}' references unknown category externalId '${ext}'`
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
const existing = stores.games.get(g.externalGameId);
|
|
145
|
+
if (!existing) {
|
|
146
|
+
stores.games.set(g.externalGameId, { game: g, status: "active" });
|
|
147
|
+
summary.games.inserted++;
|
|
148
|
+
} else if (!same(existing.game, g) || existing.status !== "active") {
|
|
149
|
+
stores.games.set(g.externalGameId, { game: g, status: "active" });
|
|
150
|
+
summary.games.updated++;
|
|
151
|
+
} else {
|
|
152
|
+
summary.games.unchanged++;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
const linkedGames = /* @__PURE__ */ new Set();
|
|
156
|
+
for (const link of delta.gameCategories ?? []) {
|
|
157
|
+
const row = stores.games.get(link.gameExternalId);
|
|
158
|
+
if (!row) {
|
|
159
|
+
throw validation(
|
|
160
|
+
`game category link references unknown game externalId '${link.gameExternalId}'`
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
if (!stores.categories.has(link.categoryExternalId)) {
|
|
164
|
+
throw validation(
|
|
165
|
+
`game category link references unknown category externalId '${link.categoryExternalId}'`
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
if (!row.game.categoryExternalIds.includes(link.categoryExternalId)) {
|
|
169
|
+
stores.games.set(link.gameExternalId, {
|
|
170
|
+
...row,
|
|
171
|
+
game: {
|
|
172
|
+
...row.game,
|
|
173
|
+
categoryExternalIds: [...row.game.categoryExternalIds, link.categoryExternalId]
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
linkedGames.add(link.gameExternalId);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
summary.gameCategoryLinks = linkedGames.size;
|
|
180
|
+
return summary;
|
|
181
|
+
};
|
|
182
|
+
const upserted = (s, family) => {
|
|
183
|
+
const c = s[family];
|
|
184
|
+
return { upserted: c.inserted + c.updated };
|
|
185
|
+
};
|
|
186
|
+
const capability = {
|
|
187
|
+
async upsertBatch(delta) {
|
|
188
|
+
return applyDelta(delta);
|
|
189
|
+
},
|
|
190
|
+
async upsertProviders(providers) {
|
|
191
|
+
return upserted(applyDelta({ providers }), "providers");
|
|
192
|
+
},
|
|
193
|
+
async upsertRestrictionGroups(groups) {
|
|
194
|
+
return upserted(applyDelta({ restrictionGroups: groups }), "restrictionGroups");
|
|
195
|
+
},
|
|
196
|
+
async upsertCurrencyGroups(groups) {
|
|
197
|
+
return upserted(applyDelta({ currencyGroups: groups }), "currencyGroups");
|
|
198
|
+
},
|
|
199
|
+
async upsertCategories(categories) {
|
|
200
|
+
return upserted(applyDelta({ categories }), "categories");
|
|
201
|
+
},
|
|
202
|
+
async upsertGames(games) {
|
|
203
|
+
return upserted(applyDelta({ games }), "games");
|
|
204
|
+
},
|
|
205
|
+
async attachGameCategories(links) {
|
|
206
|
+
applyDelta({ gameCategories: links });
|
|
207
|
+
},
|
|
208
|
+
async setGameStatus(externalGameId, enabled) {
|
|
209
|
+
const row = stores.games.get(externalGameId);
|
|
210
|
+
if (!row) {
|
|
211
|
+
throw notFound(`game '${externalGameId}' not found under source 'plugin:${manifest.key}'`);
|
|
212
|
+
}
|
|
213
|
+
stores.games.set(externalGameId, { ...row, status: enabled ? "active" : "retired" });
|
|
214
|
+
},
|
|
215
|
+
async listOwnGames(q) {
|
|
216
|
+
const limit = Math.min(Math.max(1, q?.limit ?? 50), 200);
|
|
217
|
+
let rows = [...stores.games.entries()].sort(([a], [b]) => a < b ? -1 : 1);
|
|
218
|
+
if (q?.cursor !== void 0) rows = rows.filter(([key]) => key > q.cursor);
|
|
219
|
+
const page = rows.slice(0, limit);
|
|
220
|
+
const games = page.map(([externalGameId, row]) => ({
|
|
221
|
+
gameId: `mock-${externalGameId}`,
|
|
222
|
+
externalGameId,
|
|
223
|
+
slug: null,
|
|
224
|
+
name: row.game.name ?? null,
|
|
225
|
+
launchCode: row.game.launchCode ?? null,
|
|
226
|
+
providerId: `mock-provider-${row.game.providerExternalId}`,
|
|
227
|
+
status: row.status
|
|
228
|
+
}));
|
|
229
|
+
return {
|
|
230
|
+
games,
|
|
231
|
+
...page.length === limit ? { nextCursor: page[page.length - 1][0] } : {}
|
|
232
|
+
};
|
|
233
|
+
},
|
|
234
|
+
async requestImport() {
|
|
235
|
+
return requestImport();
|
|
236
|
+
}
|
|
237
|
+
};
|
|
238
|
+
return { capability, stores };
|
|
239
|
+
}
|
|
240
|
+
function applyCatalogSnapshot(stores, capability, normalized) {
|
|
241
|
+
return (async () => {
|
|
242
|
+
const startedAt = Date.now();
|
|
243
|
+
const chunks = (rows) => {
|
|
244
|
+
const out = [];
|
|
245
|
+
for (let i = 0; i < rows.length; i += PLUGIN_CATALOG_LIMITS.maxRowsPerFamily) {
|
|
246
|
+
out.push(rows.slice(i, i + PLUGIN_CATALOG_LIMITS.maxRowsPerFamily));
|
|
247
|
+
}
|
|
248
|
+
return out.length ? out : [[]];
|
|
249
|
+
};
|
|
250
|
+
const total = {
|
|
251
|
+
providers: { inserted: 0, updated: 0 },
|
|
252
|
+
categories: { inserted: 0, updated: 0 },
|
|
253
|
+
restrictionGroups: { inserted: 0, updated: 0 },
|
|
254
|
+
currencyGroups: { inserted: 0, updated: 0 },
|
|
255
|
+
games: { inserted: 0, updated: 0, retired: 0, unchanged: 0 },
|
|
256
|
+
skippedUnknownCategoryLinks: 0,
|
|
257
|
+
durationMs: 0
|
|
258
|
+
};
|
|
259
|
+
const add = (a, b) => {
|
|
260
|
+
a.inserted += b.inserted;
|
|
261
|
+
a.updated += b.updated;
|
|
262
|
+
};
|
|
263
|
+
for (const providers of chunks(normalized.providers)) {
|
|
264
|
+
add(total.providers, (await capability.upsertBatch({ providers })).providers);
|
|
265
|
+
}
|
|
266
|
+
for (const restrictionGroups of chunks(normalized.restrictionGroups)) {
|
|
267
|
+
add(
|
|
268
|
+
total.restrictionGroups,
|
|
269
|
+
(await capability.upsertBatch({ restrictionGroups })).restrictionGroups
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
for (const currencyGroups of chunks(normalized.currencyGroups)) {
|
|
273
|
+
add(total.currencyGroups, (await capability.upsertBatch({ currencyGroups })).currencyGroups);
|
|
274
|
+
}
|
|
275
|
+
for (const categories of chunks(normalized.categories)) {
|
|
276
|
+
add(total.categories, (await capability.upsertBatch({ categories })).categories);
|
|
277
|
+
}
|
|
278
|
+
for (const games of chunks(normalized.games)) {
|
|
279
|
+
const s = (await capability.upsertBatch({ games })).games;
|
|
280
|
+
total.games.inserted += s.inserted;
|
|
281
|
+
total.games.updated += s.updated;
|
|
282
|
+
total.games.unchanged += s.unchanged;
|
|
283
|
+
}
|
|
284
|
+
const incoming = new Set(normalized.games.map((g) => g.externalGameId));
|
|
285
|
+
for (const [externalGameId, row] of stores.games) {
|
|
286
|
+
if (row.status === "active" && !incoming.has(externalGameId)) {
|
|
287
|
+
stores.games.set(externalGameId, { ...row, status: "retired" });
|
|
288
|
+
total.games.retired++;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
total.durationMs = Date.now() - startedAt;
|
|
292
|
+
return total;
|
|
293
|
+
})();
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// src/testing/context.ts
|
|
297
|
+
var REDACT_KEY_RE = /secret|password|token|apikey|api_key|credential/i;
|
|
298
|
+
function hostMatches(host, allowlist) {
|
|
299
|
+
const target = host.toLowerCase();
|
|
300
|
+
for (const raw of allowlist) {
|
|
301
|
+
const entry = raw.toLowerCase();
|
|
302
|
+
if (entry.startsWith("*.")) {
|
|
303
|
+
const suffix = entry.slice(1);
|
|
304
|
+
if (target.endsWith(suffix) && !target.slice(0, -suffix.length).includes(".")) return true;
|
|
305
|
+
} else if (target === entry) return true;
|
|
306
|
+
}
|
|
307
|
+
return false;
|
|
308
|
+
}
|
|
309
|
+
var OPTION_ONLY_KEYS = [
|
|
310
|
+
"definition",
|
|
311
|
+
"tenantId",
|
|
312
|
+
"region",
|
|
313
|
+
"settings",
|
|
314
|
+
"secrets",
|
|
315
|
+
"commandResults",
|
|
316
|
+
"httpMock",
|
|
317
|
+
"readModels",
|
|
318
|
+
"datasetQuota",
|
|
319
|
+
"sharedDatasets",
|
|
320
|
+
"datasets",
|
|
321
|
+
"http",
|
|
322
|
+
"quotas"
|
|
323
|
+
];
|
|
324
|
+
function isDefinitionForm(first, second) {
|
|
325
|
+
if (second !== void 0) return true;
|
|
326
|
+
if (typeof first !== "object" || first === null || !("manifest" in first)) return false;
|
|
327
|
+
return !OPTION_ONLY_KEYS.some((key) => key in first);
|
|
328
|
+
}
|
|
329
|
+
function createTestContext(first, second) {
|
|
330
|
+
const options = isDefinitionForm(first, second) ? { ...second ?? {}, definition: first } : first;
|
|
331
|
+
return buildTestContext(options);
|
|
332
|
+
}
|
|
333
|
+
function buildTestContext(options) {
|
|
334
|
+
const resolved = options.definition?.manifest ?? options.manifest;
|
|
335
|
+
if (!resolved) throw new Error("createTestContext requires a manifest (or definition)");
|
|
336
|
+
const manifest = resolved;
|
|
337
|
+
const handlers = options.definition?.handlers ?? options.handlers ?? {};
|
|
338
|
+
const hooks = options.definition?.hooks ?? options.hooks;
|
|
339
|
+
const tenantId = options.tenantId ?? "test-tenant";
|
|
340
|
+
const region = options.region ?? "eu";
|
|
341
|
+
const settings = { ...options.settings ?? {} };
|
|
342
|
+
const secrets = new Map(Object.entries(options.secrets ?? {}));
|
|
343
|
+
const allowedCommands = new Set(manifest.permissions.commands);
|
|
344
|
+
const allowedSubscribe = new Set(manifest.permissions.events.subscribe);
|
|
345
|
+
const allowedEmit = new Set(manifest.permissions.events.emit);
|
|
346
|
+
const grantedScopes = new Set(manifest.permissions.dataScopes ?? []);
|
|
347
|
+
const allowedHosts = [
|
|
348
|
+
...manifest.network?.allowedHosts ?? [],
|
|
349
|
+
...manifest.permissions.networkAllow ?? []
|
|
350
|
+
];
|
|
351
|
+
const emitPrefix = `plugin.${manifest.key}.`;
|
|
352
|
+
const httpHostMocks = new Map(
|
|
353
|
+
Object.entries(options.http ?? {}).map(([host, mock]) => [host.toLowerCase(), mock])
|
|
354
|
+
);
|
|
355
|
+
const commands = [];
|
|
356
|
+
const events = [];
|
|
357
|
+
const httpExchanges = [];
|
|
358
|
+
const logs = [];
|
|
359
|
+
const tasksStarted = [];
|
|
360
|
+
const eventHandlers = /* @__PURE__ */ new Map();
|
|
361
|
+
const datasetStores = /* @__PURE__ */ new Map();
|
|
362
|
+
const storeOf = (dataset) => {
|
|
363
|
+
let store = datasetStores.get(dataset);
|
|
364
|
+
if (!store) {
|
|
365
|
+
store = /* @__PURE__ */ new Map();
|
|
366
|
+
datasetStores.set(dataset, store);
|
|
367
|
+
}
|
|
368
|
+
return store;
|
|
369
|
+
};
|
|
370
|
+
function ownCollection(dataset) {
|
|
371
|
+
const decl = manifest.datasets?.[dataset];
|
|
372
|
+
if (!decl) {
|
|
373
|
+
throw notFound(`Plugin '${manifest.key}' does not declare a dataset named '${dataset}'`);
|
|
374
|
+
}
|
|
375
|
+
const indexes = (decl.indexes ?? []).slice(0, PLUGIN_DATASET_LIMITS.maxIndexes);
|
|
376
|
+
const quota = options.quotas?.[dataset] ?? options.datasetQuota ?? decl.maxRecords ?? PLUGIN_DATASET_LIMITS.defaultMaxRecords;
|
|
377
|
+
const store = storeOf(dataset);
|
|
378
|
+
const validate = (key, value) => {
|
|
379
|
+
const parsed = decl.schema.safeParse(value);
|
|
380
|
+
if (!parsed.success) {
|
|
381
|
+
throw validation(`Record '${key}' does not match the '${dataset}' dataset schema`, {
|
|
382
|
+
issues: parsed.error.issues.slice(0, 5)
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
const data = parsed.data;
|
|
386
|
+
const keyFieldValue = data[decl.keyField];
|
|
387
|
+
if (keyFieldValue !== void 0 && String(keyFieldValue) !== key) {
|
|
388
|
+
throw validation(`Record key '${key}' does not match its '${decl.keyField}' field`);
|
|
389
|
+
}
|
|
390
|
+
const bytes = Buffer.byteLength(JSON.stringify(data), "utf8");
|
|
391
|
+
if (bytes > PLUGIN_DATASET_LIMITS.maxRecordBytes) {
|
|
392
|
+
throw validation(
|
|
393
|
+
`Record '${key}' is ${bytes} bytes; the cap is ${PLUGIN_DATASET_LIMITS.maxRecordBytes}`
|
|
394
|
+
);
|
|
395
|
+
}
|
|
396
|
+
return data;
|
|
397
|
+
};
|
|
398
|
+
return {
|
|
399
|
+
async get(key) {
|
|
400
|
+
return store.get(key)?.value ?? null;
|
|
401
|
+
},
|
|
402
|
+
async put(key, value) {
|
|
403
|
+
await this.putMany([{ key, value }]);
|
|
404
|
+
},
|
|
405
|
+
async putMany(records) {
|
|
406
|
+
if (records.length === 0) return;
|
|
407
|
+
if (records.length > PLUGIN_DATASET_LIMITS.maxBatch) {
|
|
408
|
+
throw validation(
|
|
409
|
+
`putMany accepts at most ${PLUGIN_DATASET_LIMITS.maxBatch} records per call`
|
|
410
|
+
);
|
|
411
|
+
}
|
|
412
|
+
const validated = records.map(({ key, value }) => ({ key, data: validate(key, value) }));
|
|
413
|
+
const newCount = validated.filter(({ key }) => !store.has(key)).length;
|
|
414
|
+
if (store.size + newCount > quota) {
|
|
415
|
+
throw quotaExceeded(
|
|
416
|
+
`Dataset '${manifest.key}.${dataset}' quota exceeded (${store.size}+${newCount} > ${quota})`,
|
|
417
|
+
{ current: store.size, incoming: newCount, quota }
|
|
418
|
+
);
|
|
419
|
+
}
|
|
420
|
+
for (const { key, data } of validated) store.set(key, { key, value: data });
|
|
421
|
+
},
|
|
422
|
+
async delete(key) {
|
|
423
|
+
store.delete(key);
|
|
424
|
+
},
|
|
425
|
+
async query(q) {
|
|
426
|
+
for (const field of Object.keys(q.where ?? {})) {
|
|
427
|
+
if (!indexes.includes(field)) {
|
|
428
|
+
throw validation(`Field '${field}' is not an indexed field of dataset '${dataset}'`);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
const limit = Math.min(Math.max(1, q.limit ?? 50), PLUGIN_DATASET_LIMITS.maxQueryLimit);
|
|
432
|
+
let rows = [...store.values()].sort((a, b) => a.key < b.key ? -1 : 1);
|
|
433
|
+
for (const [field, value] of Object.entries(q.where ?? {})) {
|
|
434
|
+
rows = rows.filter((r) => String(r.value[field]) === String(value));
|
|
435
|
+
}
|
|
436
|
+
if (q.cursor !== void 0) rows = rows.filter((r) => r.key > q.cursor);
|
|
437
|
+
const page = rows.slice(0, limit);
|
|
438
|
+
return {
|
|
439
|
+
records: page.map((r) => ({ key: r.key, value: r.value })),
|
|
440
|
+
...page.length === limit ? { nextCursor: page[page.length - 1].key } : {}
|
|
441
|
+
};
|
|
442
|
+
},
|
|
443
|
+
async count() {
|
|
444
|
+
return store.size;
|
|
445
|
+
},
|
|
446
|
+
async truncate() {
|
|
447
|
+
store.clear();
|
|
448
|
+
}
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
function foreignCollection(name) {
|
|
452
|
+
const declared = manifest.permissions.datasets?.read?.includes(name) === true;
|
|
453
|
+
const fixture = options.sharedDatasets?.[name];
|
|
454
|
+
if (!declared || !fixture) {
|
|
455
|
+
throw forbidden(`Plugin '${manifest.key}' may not read dataset '${name}'`);
|
|
456
|
+
}
|
|
457
|
+
const rows = new Map(fixture.map((r) => [r.key, r]));
|
|
458
|
+
const readOnly = () => {
|
|
459
|
+
throw forbidden(`Plugin '${manifest.key}' has read-only access to '${name}'`);
|
|
460
|
+
};
|
|
461
|
+
return {
|
|
462
|
+
get: async (key) => rows.get(key)?.value ?? null,
|
|
463
|
+
query: async (q) => {
|
|
464
|
+
const limit = Math.min(Math.max(1, q.limit ?? 50), PLUGIN_DATASET_LIMITS.maxQueryLimit);
|
|
465
|
+
let sorted = [...rows.values()].sort((a, b) => a.key < b.key ? -1 : 1);
|
|
466
|
+
if (q.cursor !== void 0) sorted = sorted.filter((r) => r.key > q.cursor);
|
|
467
|
+
const page = sorted.slice(0, limit);
|
|
468
|
+
return {
|
|
469
|
+
records: page.map((r) => ({ key: r.key, value: r.value })),
|
|
470
|
+
...page.length === limit ? { nextCursor: page[page.length - 1].key } : {}
|
|
471
|
+
};
|
|
472
|
+
},
|
|
473
|
+
count: async () => rows.size,
|
|
474
|
+
put: readOnly,
|
|
475
|
+
putMany: readOnly,
|
|
476
|
+
delete: readOnly,
|
|
477
|
+
truncate: readOnly
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
const redact = (extra) => {
|
|
481
|
+
const out = {};
|
|
482
|
+
for (const [k, v] of Object.entries(extra ?? {})) {
|
|
483
|
+
out[k] = REDACT_KEY_RE.test(k) ? "[redacted]" : v;
|
|
484
|
+
}
|
|
485
|
+
return out;
|
|
486
|
+
};
|
|
487
|
+
const log = (level) => (message, extra) => {
|
|
488
|
+
logs.push({ level, message, extra: redact(extra) });
|
|
489
|
+
};
|
|
490
|
+
const ctx = {
|
|
491
|
+
tenantId,
|
|
492
|
+
region,
|
|
493
|
+
pluginKey: manifest.key,
|
|
494
|
+
version: manifest.version,
|
|
495
|
+
commands: {
|
|
496
|
+
async execute(command, opts) {
|
|
497
|
+
if (!allowedCommands.has(command.name)) {
|
|
498
|
+
throw forbidden(
|
|
499
|
+
`Plugin '${manifest.key}' is not permitted to execute command '${command.name}'`
|
|
500
|
+
);
|
|
501
|
+
}
|
|
502
|
+
commands.push({ name: command.name, input: command.input, ...opts ?? {} });
|
|
503
|
+
const stub = options.commandResults?.[command.name];
|
|
504
|
+
if (stub instanceof Error) throw stub;
|
|
505
|
+
if (typeof stub === "function") {
|
|
506
|
+
return await stub(command.input);
|
|
507
|
+
}
|
|
508
|
+
return stub ?? {};
|
|
509
|
+
}
|
|
510
|
+
},
|
|
511
|
+
settings: {
|
|
512
|
+
get() {
|
|
513
|
+
return settings;
|
|
514
|
+
}
|
|
515
|
+
},
|
|
516
|
+
secrets: {
|
|
517
|
+
get: async (key) => secrets.get(key)
|
|
518
|
+
},
|
|
519
|
+
events: {
|
|
520
|
+
on(event, handler) {
|
|
521
|
+
if (!allowedSubscribe.has(event)) {
|
|
522
|
+
throw forbidden(`Plugin '${manifest.key}' is not permitted to subscribe to '${event}'`);
|
|
523
|
+
}
|
|
524
|
+
const list = eventHandlers.get(event) ?? [];
|
|
525
|
+
list.push(handler);
|
|
526
|
+
eventHandlers.set(event, list);
|
|
527
|
+
},
|
|
528
|
+
async emit(event) {
|
|
529
|
+
if (!event.name.startsWith(emitPrefix) || !allowedEmit.has(event.name)) {
|
|
530
|
+
throw forbidden(`Plugin '${manifest.key}' is not permitted to emit '${event.name}'`);
|
|
531
|
+
}
|
|
532
|
+
events.push(event);
|
|
533
|
+
}
|
|
534
|
+
},
|
|
535
|
+
data: {
|
|
536
|
+
async query(model, params) {
|
|
537
|
+
const required = ReadModelRequiredScopes[model];
|
|
538
|
+
if (!required) throw notFound(`Unknown read model '${String(model)}'`);
|
|
539
|
+
if (!grantedScopes.has(required)) {
|
|
540
|
+
throw forbidden(
|
|
541
|
+
`Plugin '${manifest.key}' lacks scope '${required}' for read model '${String(model)}'`
|
|
542
|
+
);
|
|
543
|
+
}
|
|
544
|
+
const fixture = options.readModels?.[model];
|
|
545
|
+
if (fixture === void 0) {
|
|
546
|
+
throw new Error(
|
|
547
|
+
`No fixture for read model '${String(model)}' \u2014 pass options.readModels['${String(model)}']`
|
|
548
|
+
);
|
|
549
|
+
}
|
|
550
|
+
const value = typeof fixture === "function" ? await fixture(params) : fixture;
|
|
551
|
+
return value;
|
|
552
|
+
}
|
|
553
|
+
},
|
|
554
|
+
datasets: {
|
|
555
|
+
collection(name) {
|
|
556
|
+
const dot = name.indexOf(".");
|
|
557
|
+
if (dot === -1 || name.slice(0, dot) === manifest.key) {
|
|
558
|
+
return ownCollection(dot === -1 ? name : name.slice(dot + 1));
|
|
559
|
+
}
|
|
560
|
+
return foreignCollection(name);
|
|
561
|
+
}
|
|
562
|
+
},
|
|
563
|
+
http: {
|
|
564
|
+
async fetch(url, init) {
|
|
565
|
+
let parsed;
|
|
566
|
+
try {
|
|
567
|
+
parsed = new URL(url);
|
|
568
|
+
} catch {
|
|
569
|
+
throw validation(`'${url}' is not a valid URL`);
|
|
570
|
+
}
|
|
571
|
+
if (parsed.protocol !== "https:") {
|
|
572
|
+
throw egressDenied(
|
|
573
|
+
`Plugin '${manifest.key}' may only call HTTPS endpoints (got ${parsed.protocol}//)`
|
|
574
|
+
);
|
|
575
|
+
}
|
|
576
|
+
if (!hostMatches(parsed.hostname, allowedHosts)) {
|
|
577
|
+
throw egressDenied(
|
|
578
|
+
`Host '${parsed.hostname}' is not in plugin '${manifest.key}' allowedHosts`
|
|
579
|
+
);
|
|
580
|
+
}
|
|
581
|
+
const real = { ...init?.headers ?? {} };
|
|
582
|
+
const redacted = { ...init?.headers ?? {} };
|
|
583
|
+
for (const [header, secretName] of Object.entries(init?.secretHeaders ?? {})) {
|
|
584
|
+
const secret = secrets.get(secretName);
|
|
585
|
+
if (secret === void 0) {
|
|
586
|
+
throw validation(`secretHeaders['${header}'] references unknown secret '${secretName}'`);
|
|
587
|
+
}
|
|
588
|
+
real[header] = secret;
|
|
589
|
+
redacted[header] = `<redacted:${secretName}>`;
|
|
590
|
+
}
|
|
591
|
+
const bodyText = init?.body === void 0 ? void 0 : typeof init.body === "string" ? init.body : JSON.stringify(init.body);
|
|
592
|
+
const request = {
|
|
593
|
+
url,
|
|
594
|
+
method: init?.method ?? "GET",
|
|
595
|
+
headers: real,
|
|
596
|
+
...bodyText !== void 0 ? { body: bodyText } : {}
|
|
597
|
+
};
|
|
598
|
+
const hostMock = httpHostMocks.get(parsed.hostname.toLowerCase());
|
|
599
|
+
let status;
|
|
600
|
+
let text;
|
|
601
|
+
let responseHeaders;
|
|
602
|
+
if (hostMock) {
|
|
603
|
+
const response = await hostMock(request);
|
|
604
|
+
status = response.status;
|
|
605
|
+
text = typeof response.body === "string" ? response.body : JSON.stringify(response.body ?? null);
|
|
606
|
+
responseHeaders = response.headers ?? {};
|
|
607
|
+
} else {
|
|
608
|
+
if (!options.httpMock) {
|
|
609
|
+
throw new Error("ctx.http.fetch called without options.httpMock \u2014 provide one");
|
|
610
|
+
}
|
|
611
|
+
const response = await options.httpMock(request);
|
|
612
|
+
status = response.status;
|
|
613
|
+
text = response.text ?? JSON.stringify(response.json ?? null);
|
|
614
|
+
responseHeaders = response.headers ?? {};
|
|
615
|
+
}
|
|
616
|
+
httpExchanges.push({
|
|
617
|
+
url: `${parsed.origin}${parsed.pathname}`,
|
|
618
|
+
method: init?.method ?? "GET",
|
|
619
|
+
headers: redacted,
|
|
620
|
+
status
|
|
621
|
+
});
|
|
622
|
+
return {
|
|
623
|
+
status,
|
|
624
|
+
ok: status >= 200 && status < 300,
|
|
625
|
+
headers: responseHeaders,
|
|
626
|
+
bodyText: text,
|
|
627
|
+
json() {
|
|
628
|
+
return JSON.parse(text);
|
|
629
|
+
}
|
|
630
|
+
};
|
|
631
|
+
},
|
|
632
|
+
async hmacSha256(secretName, payload) {
|
|
633
|
+
const secret = secrets.get(secretName);
|
|
634
|
+
if (secret === void 0) throw validation(`Unknown secret '${secretName}'`);
|
|
635
|
+
return createHmac("sha256", secret).update(payload).digest("hex");
|
|
636
|
+
}
|
|
637
|
+
},
|
|
638
|
+
tasks: {
|
|
639
|
+
async start(type, input) {
|
|
640
|
+
tasksStarted.push({ type, ...input !== void 0 ? { input } : {} });
|
|
641
|
+
return { taskId: `test-task-${tasksStarted.length}` };
|
|
642
|
+
}
|
|
643
|
+
},
|
|
644
|
+
logger: {
|
|
645
|
+
debug: log("debug"),
|
|
646
|
+
info: log("info"),
|
|
647
|
+
warn: log("warn"),
|
|
648
|
+
error: log("error")
|
|
649
|
+
}
|
|
650
|
+
};
|
|
651
|
+
if (manifest.kind === "provider") {
|
|
652
|
+
ctx.registerProviderAdapter = (adapter) => {
|
|
653
|
+
harness.providerAdapter = adapter;
|
|
654
|
+
};
|
|
655
|
+
}
|
|
656
|
+
let catalogStores;
|
|
657
|
+
if (catalogGrantedFor(manifest)) {
|
|
658
|
+
const mock = createCatalogMock(manifest, async () => {
|
|
659
|
+
tasksStarted.push({ type: CATALOG_IMPORT_TASK_TYPE });
|
|
660
|
+
return { taskId: `test-task-${tasksStarted.length}` };
|
|
661
|
+
});
|
|
662
|
+
catalogStores = mock.stores;
|
|
663
|
+
ctx.catalog = mock.capability;
|
|
664
|
+
ctx.registerCatalogSource = (adapter) => {
|
|
665
|
+
harness.catalogSourceAdapter = adapter;
|
|
666
|
+
};
|
|
667
|
+
}
|
|
668
|
+
const findRoute = (ref) => {
|
|
669
|
+
if (typeof ref !== "string") return ref;
|
|
670
|
+
const withMethod = /^([A-Z]+)\s+(.+)$/.exec(ref);
|
|
671
|
+
const method = withMethod?.[1];
|
|
672
|
+
const path = withMethod?.[2] ?? ref;
|
|
673
|
+
const decl = (manifest.routes ?? []).find(
|
|
674
|
+
(r) => r.path === path && (method === void 0 || r.method === method)
|
|
675
|
+
);
|
|
676
|
+
if (!decl) throw notFound(`No declared route matching '${ref}'`);
|
|
677
|
+
return decl;
|
|
678
|
+
};
|
|
679
|
+
const harness = {
|
|
680
|
+
ctx,
|
|
681
|
+
commands,
|
|
682
|
+
events,
|
|
683
|
+
httpExchanges,
|
|
684
|
+
logs,
|
|
685
|
+
tasksStarted,
|
|
686
|
+
datasetStores,
|
|
687
|
+
/** In-memory catalog stores (undefined unless `manifest.catalog` granted). */
|
|
688
|
+
catalogStores,
|
|
689
|
+
providerAdapter: void 0,
|
|
690
|
+
/** Captured by `ctx.registerCatalogSource` during `runSetup()`. */
|
|
691
|
+
catalogSourceAdapter: void 0,
|
|
692
|
+
/**
|
|
693
|
+
* Run the registered catalog source adapter the way the host's reserved
|
|
694
|
+
* `catalog:import` task does: `ingest(ctx)` → apply as a full snapshot
|
|
695
|
+
* (upsert every family, RETIRE games absent from the snapshot) → summary.
|
|
696
|
+
*/
|
|
697
|
+
async runImport() {
|
|
698
|
+
if (!ctx.catalog || !catalogStores) {
|
|
699
|
+
throw forbidden(`Plugin '${manifest.key}' has no catalog grant (manifest.catalog missing)`);
|
|
700
|
+
}
|
|
701
|
+
const adapter = harness.catalogSourceAdapter;
|
|
702
|
+
if (!adapter) {
|
|
703
|
+
throw notFound(
|
|
704
|
+
`Plugin '${manifest.key}' registered no catalog source adapter \u2014 call harness.runSetup() first`
|
|
705
|
+
);
|
|
706
|
+
}
|
|
707
|
+
const normalized = await adapter.ingest(ctx);
|
|
708
|
+
return applyCatalogSnapshot(catalogStores, ctx.catalog, normalized);
|
|
709
|
+
},
|
|
710
|
+
/** Deliver a domain event to handlers registered via `ctx.events.on`. */
|
|
711
|
+
async dispatch(event) {
|
|
712
|
+
for (const handler of eventHandlers.get(event.name) ?? []) {
|
|
713
|
+
await handler(event);
|
|
714
|
+
}
|
|
715
|
+
},
|
|
716
|
+
async runHook(name, ...args) {
|
|
717
|
+
const hook = hooks?.[name];
|
|
718
|
+
if (!hook) return void 0;
|
|
719
|
+
return hook(ctx, ...args);
|
|
720
|
+
},
|
|
721
|
+
async runSetup() {
|
|
722
|
+
await options.definition?.setup?.(ctx);
|
|
723
|
+
},
|
|
724
|
+
/** Zod-validate inputs per the declaration, enforce surface auth, invoke. */
|
|
725
|
+
async invokeRoute(ref, request = {}) {
|
|
726
|
+
const decl = findRoute(ref);
|
|
727
|
+
const handler = handlers.routes?.[decl.handler];
|
|
728
|
+
if (!handler) throw notFound(`Handler '${decl.handler}' not found in handlers.routes`);
|
|
729
|
+
if (decl.surface === "player" && !request.player) {
|
|
730
|
+
throw unauthorized("Player session required");
|
|
731
|
+
}
|
|
732
|
+
if (decl.surface === "admin" && !request.actor) {
|
|
733
|
+
throw unauthorized("Staff actor required");
|
|
734
|
+
}
|
|
735
|
+
if (decl.idempotent && !request.idempotencyKey) {
|
|
736
|
+
throw validation("Idempotency-Key header is required for this route");
|
|
737
|
+
}
|
|
738
|
+
const parse = (schema, value) => schema ? schema.parse(value) : value;
|
|
739
|
+
const rawBody = request.rawBody ?? JSON.stringify(request.body ?? {});
|
|
740
|
+
const headers = Object.fromEntries(
|
|
741
|
+
Object.entries(request.headers ?? {}).map(([k, v]) => [k.toLowerCase(), v])
|
|
742
|
+
);
|
|
743
|
+
const verifySignature = async (secretName, opts) => {
|
|
744
|
+
if (request.trustSignature) return;
|
|
745
|
+
const secret = secrets.get(secretName);
|
|
746
|
+
if (!secret) throw unauthorized("Signature secret is not configured");
|
|
747
|
+
const headerName = (opts?.header ?? "x-signature").toLowerCase();
|
|
748
|
+
const provided = headers[headerName];
|
|
749
|
+
if (!provided) throw unauthorized("Missing signature");
|
|
750
|
+
const expected = createHmac(opts?.algorithm ?? "sha256", secret).update(rawBody).digest(opts?.encoding ?? "hex");
|
|
751
|
+
if (provided !== expected) throw unauthorized("Invalid signature");
|
|
752
|
+
};
|
|
753
|
+
const pluginRequest = {
|
|
754
|
+
params: parse(decl.input?.params, request.params ?? {}),
|
|
755
|
+
query: parse(decl.input?.query, request.query ?? {}),
|
|
756
|
+
body: parse(decl.input?.body, request.body),
|
|
757
|
+
...request.player ? { player: request.player } : {},
|
|
758
|
+
...request.actor ? { actor: request.actor } : {},
|
|
759
|
+
headers: Object.freeze(headers),
|
|
760
|
+
...request.idempotencyKey ? { idempotencyKey: request.idempotencyKey } : {},
|
|
761
|
+
verifySignature
|
|
762
|
+
};
|
|
763
|
+
return handler(pluginRequest, ctx);
|
|
764
|
+
},
|
|
765
|
+
async runJob(name) {
|
|
766
|
+
const decl = manifest.jobs?.[name];
|
|
767
|
+
if (!decl) throw notFound(`Plugin '${manifest.key}' has no job '${name}'`);
|
|
768
|
+
const handler = handlers.jobs?.[decl.handler];
|
|
769
|
+
if (!handler) throw notFound(`Handler '${decl.handler}' not found in handlers.jobs`);
|
|
770
|
+
await handler(ctx);
|
|
771
|
+
},
|
|
772
|
+
async runTask(type, input = {}, checkpoint = null) {
|
|
773
|
+
const handler = handlers.tasks?.[type];
|
|
774
|
+
if (!handler) throw notFound(`Handler '${type}' not found in handlers.tasks`);
|
|
775
|
+
let current = checkpoint;
|
|
776
|
+
let percent = 0;
|
|
777
|
+
let message;
|
|
778
|
+
const progress = {
|
|
779
|
+
report: async (p, m) => {
|
|
780
|
+
percent = p;
|
|
781
|
+
message = m;
|
|
782
|
+
},
|
|
783
|
+
get checkpoint() {
|
|
784
|
+
return current;
|
|
785
|
+
},
|
|
786
|
+
saveCheckpoint: async (cp) => {
|
|
787
|
+
current = cp;
|
|
788
|
+
}
|
|
789
|
+
};
|
|
790
|
+
await handler(ctx, input, progress);
|
|
791
|
+
return { progress: percent, ...message !== void 0 ? { message } : {}, checkpoint: current };
|
|
792
|
+
},
|
|
793
|
+
/** Run declared migrations with semver `toVersion` (in-memory datasets). */
|
|
794
|
+
async runMigration(toVersion) {
|
|
795
|
+
const decl = (manifest.migrations ?? []).find((m) => m.toVersion === toVersion);
|
|
796
|
+
if (!decl) throw notFound(`No migration with toVersion '${toVersion}'`);
|
|
797
|
+
const handler = handlers.migrations?.[decl.handler];
|
|
798
|
+
if (!handler) throw notFound(`Handler '${decl.handler}' not found in handlers.migrations`);
|
|
799
|
+
let totalProcessed = 0;
|
|
800
|
+
const helper = {
|
|
801
|
+
async transformDataset(dataset, transform) {
|
|
802
|
+
const store = storeOf(dataset);
|
|
803
|
+
const collection = ownCollection(dataset);
|
|
804
|
+
const keys = [...store.keys()].sort();
|
|
805
|
+
let processed = 0;
|
|
806
|
+
for (let i = 0; i < keys.length; i += PLUGIN_MIGRATION_LIMITS.batchSize) {
|
|
807
|
+
const batch = keys.slice(i, i + PLUGIN_MIGRATION_LIMITS.batchSize);
|
|
808
|
+
const out = [];
|
|
809
|
+
for (const key of batch) {
|
|
810
|
+
const result = await transform({
|
|
811
|
+
key,
|
|
812
|
+
value: store.get(key).value
|
|
813
|
+
});
|
|
814
|
+
if (result) out.push(result);
|
|
815
|
+
}
|
|
816
|
+
if (out.length > 0) await collection.putMany(out);
|
|
817
|
+
processed += batch.length;
|
|
818
|
+
}
|
|
819
|
+
totalProcessed += processed;
|
|
820
|
+
return { processed };
|
|
821
|
+
}
|
|
822
|
+
};
|
|
823
|
+
await handler(ctx, helper);
|
|
824
|
+
return { processed: totalProcessed };
|
|
825
|
+
},
|
|
826
|
+
/** Seed a dataset store directly (bypasses schema — arrange step only). */
|
|
827
|
+
seedDataset(dataset, records) {
|
|
828
|
+
const store = storeOf(dataset);
|
|
829
|
+
for (const r of records) store.set(r.key, { key: r.key, value: r.value });
|
|
830
|
+
},
|
|
831
|
+
setSecret(key, value) {
|
|
832
|
+
if (value === void 0) secrets.delete(key);
|
|
833
|
+
else secrets.set(key, value);
|
|
834
|
+
},
|
|
835
|
+
setSettings(next) {
|
|
836
|
+
Object.assign(settings, next);
|
|
837
|
+
}
|
|
838
|
+
};
|
|
839
|
+
for (const [dataset, records] of Object.entries(options.datasets ?? {})) {
|
|
840
|
+
harness.seedDataset(
|
|
841
|
+
dataset,
|
|
842
|
+
records.map((r) => ({ key: r.key, value: r.value }))
|
|
843
|
+
);
|
|
844
|
+
}
|
|
845
|
+
const recorder = {
|
|
846
|
+
commands,
|
|
847
|
+
events,
|
|
848
|
+
http: httpExchanges,
|
|
849
|
+
logs,
|
|
850
|
+
tasksStarted
|
|
851
|
+
};
|
|
852
|
+
return {
|
|
853
|
+
ctx,
|
|
854
|
+
harness,
|
|
855
|
+
recorder,
|
|
856
|
+
// Top-level aliases of the harness runners (§10 ergonomics). These are
|
|
857
|
+
// direct references — the harness methods close over module state and
|
|
858
|
+
// must stay `this`-free (runImport, which uses `harness.`, is not aliased).
|
|
859
|
+
runHook: harness.runHook,
|
|
860
|
+
runJob: harness.runJob,
|
|
861
|
+
runTask: harness.runTask,
|
|
862
|
+
runMigration: harness.runMigration,
|
|
863
|
+
invokeRoute: harness.invokeRoute,
|
|
864
|
+
dispatch: harness.dispatch,
|
|
865
|
+
runSetup: harness.runSetup,
|
|
866
|
+
seedDataset: harness.seedDataset,
|
|
867
|
+
setSettings: harness.setSettings,
|
|
868
|
+
setSecret: harness.setSecret
|
|
869
|
+
};
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
// src/testing/validate.ts
|
|
873
|
+
var ROUTE_PATH_RE = /^\/[A-Za-z0-9_\-./:%]*$/;
|
|
874
|
+
var SURFACES = /* @__PURE__ */ new Set(["public", "player", "admin", "callback"]);
|
|
875
|
+
var HOST_RE = /^(\*\.)?[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/i;
|
|
876
|
+
var SEMVER_RE = /^\d+\.\d+\.\d+(?:[-+].*)?$/;
|
|
877
|
+
var KEY_RE = /^[a-z0-9][a-z0-9-]*$/;
|
|
878
|
+
function isValidCronSyntax(expression) {
|
|
879
|
+
const fields = expression.trim().split(/\s+/);
|
|
880
|
+
if (fields.length !== 5) return false;
|
|
881
|
+
return fields.every((f) => /^[0-9*,/-]+$/.test(f));
|
|
882
|
+
}
|
|
883
|
+
function validateManifest(definition) {
|
|
884
|
+
const errors = [];
|
|
885
|
+
const warnings = [];
|
|
886
|
+
const manifest = definition.manifest;
|
|
887
|
+
const handlers = definition.handlers ?? {};
|
|
888
|
+
if (!KEY_RE.test(manifest.key)) errors.push(`key '${manifest.key}': must be a lowercase slug`);
|
|
889
|
+
if (!SEMVER_RE.test(manifest.version)) {
|
|
890
|
+
errors.push(`version '${manifest.version}': not SemVer x.y.z`);
|
|
891
|
+
}
|
|
892
|
+
const emitPrefix = `plugin.${manifest.key}.`;
|
|
893
|
+
for (const emitted of manifest.permissions.events.emit) {
|
|
894
|
+
if (!emitted.startsWith(emitPrefix)) {
|
|
895
|
+
errors.push(`emitted event '${emitted}' must be namespaced '${emitPrefix}*'`);
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
const seenRoutes = /* @__PURE__ */ new Set();
|
|
899
|
+
for (const route of manifest.routes ?? []) {
|
|
900
|
+
const label = `${route.method} ${route.surface}${route.path}`;
|
|
901
|
+
if (!SURFACES.has(route.surface)) errors.push(`route ${label}: unknown surface`);
|
|
902
|
+
if (!ROUTE_PATH_RE.test(route.path) || route.path.includes("..")) {
|
|
903
|
+
errors.push(`route ${label}: path must look like '/lobby' or '/games/:gameKey'`);
|
|
904
|
+
}
|
|
905
|
+
const dup = `${route.surface} ${route.method} ${route.path}`;
|
|
906
|
+
if (seenRoutes.has(dup)) errors.push(`route ${label}: duplicate declaration`);
|
|
907
|
+
seenRoutes.add(dup);
|
|
908
|
+
if (!handlers.routes?.[route.handler]) {
|
|
909
|
+
errors.push(`route ${label}: handler '${route.handler}' not found in handlers.routes`);
|
|
910
|
+
} else if (route.surface === "callback") {
|
|
911
|
+
const src = handlers.routes[route.handler].toString();
|
|
912
|
+
if (!src.includes("verifySignature")) {
|
|
913
|
+
errors.push(
|
|
914
|
+
`route ${label}: callback handlers must call req.verifySignature(...) before trusting the body`
|
|
915
|
+
);
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
for (const [name, decl] of Object.entries(manifest.datasets ?? {})) {
|
|
920
|
+
const fields = Object.keys(decl.schema.shape);
|
|
921
|
+
if (!fields.includes(decl.keyField)) {
|
|
922
|
+
errors.push(`dataset '${name}': keyField '${decl.keyField}' is not a schema field`);
|
|
923
|
+
}
|
|
924
|
+
const indexes = decl.indexes ?? [];
|
|
925
|
+
if (indexes.length > PLUGIN_DATASET_LIMITS.maxIndexes) {
|
|
926
|
+
errors.push(`dataset '${name}': at most ${PLUGIN_DATASET_LIMITS.maxIndexes} indexes`);
|
|
927
|
+
}
|
|
928
|
+
for (const idx of indexes) {
|
|
929
|
+
if (!fields.includes(idx)) {
|
|
930
|
+
errors.push(`dataset '${name}': index '${idx}' is not a top-level schema field`);
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
for (const [name, decl] of Object.entries(manifest.jobs ?? {})) {
|
|
935
|
+
if (!isValidCronSyntax(decl.schedule)) {
|
|
936
|
+
errors.push(`job '${name}': '${decl.schedule}' is not a 5-field cron expression`);
|
|
937
|
+
}
|
|
938
|
+
if (!handlers.jobs?.[decl.handler]) {
|
|
939
|
+
errors.push(`job '${name}': handler '${decl.handler}' not found in handlers.jobs`);
|
|
940
|
+
}
|
|
941
|
+
if (decl.timeoutSec !== void 0 && (decl.timeoutSec < 1 || decl.timeoutSec > PLUGIN_JOB_LIMITS.maxTimeoutSec)) {
|
|
942
|
+
errors.push(`job '${name}': timeoutSec must be 1..${PLUGIN_JOB_LIMITS.maxTimeoutSec}`);
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
if (manifest.install?.seedTask && !handlers.tasks?.[manifest.install.seedTask]) {
|
|
946
|
+
errors.push(`install.seedTask '${manifest.install.seedTask}' not found in handlers.tasks`);
|
|
947
|
+
}
|
|
948
|
+
for (const migration of manifest.migrations ?? []) {
|
|
949
|
+
if (!SEMVER_RE.test(migration.toVersion)) {
|
|
950
|
+
errors.push(`migration '${migration.toVersion}': not a valid SemVer version`);
|
|
951
|
+
}
|
|
952
|
+
if (!handlers.migrations?.[migration.handler]) {
|
|
953
|
+
errors.push(
|
|
954
|
+
`migration '${migration.toVersion}': handler '${migration.handler}' not found in handlers.migrations`
|
|
955
|
+
);
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
for (const host of manifest.network?.allowedHosts ?? []) {
|
|
959
|
+
if (!HOST_RE.test(host)) {
|
|
960
|
+
errors.push(`network.allowedHosts '${host}': exact host or '*.example.com' only`);
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
const knownScopes = new Set(DataScopes);
|
|
964
|
+
for (const scope of manifest.permissions.dataScopes ?? []) {
|
|
965
|
+
if (!knownScopes.has(scope)) {
|
|
966
|
+
warnings.push(`dataScope '${scope}' is not a v2 scope \u2014 it grants no read models`);
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
for (const dep of manifest.permissions.datasets?.read ?? []) {
|
|
970
|
+
if (!dep.includes(".")) {
|
|
971
|
+
errors.push(`permissions.datasets.read '${dep}': must be '<ownerPluginKey>.<dataset>'`);
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
const adminRoutes = new Set(
|
|
975
|
+
(manifest.routes ?? []).filter((r) => r.surface === "admin").map((r) => r.path)
|
|
976
|
+
);
|
|
977
|
+
const pageKeys = /* @__PURE__ */ new Set();
|
|
978
|
+
for (const page of manifest.surfaces?.backoffice?.pages ?? []) {
|
|
979
|
+
if (pageKeys.has(page.key)) errors.push(`surface page '${page.key}': duplicate key`);
|
|
980
|
+
pageKeys.add(page.key);
|
|
981
|
+
for (const block of page.blocks) {
|
|
982
|
+
const ref = "dataRoute" in block ? block.dataRoute : block.submitRoute;
|
|
983
|
+
if (!adminRoutes.has(ref)) {
|
|
984
|
+
errors.push(`surface page '${page.key}': '${ref}' is not a declared admin route`);
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
for (const nav of manifest.surfaces?.backoffice?.nav ?? []) {
|
|
989
|
+
if (!pageKeys.has(nav.pageKey)) {
|
|
990
|
+
errors.push(`surface nav '${nav.label}': pageKey '${nav.pageKey}' has no page`);
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
const actionIssues = validateActionDecls(definition);
|
|
994
|
+
errors.push(...actionIssues.errors);
|
|
995
|
+
warnings.push(...actionIssues.warnings);
|
|
996
|
+
return { ok: errors.length === 0, errors, warnings };
|
|
997
|
+
}
|
|
998
|
+
export {
|
|
999
|
+
TestContextError,
|
|
1000
|
+
catalogGrantedFor,
|
|
1001
|
+
createTestContext,
|
|
1002
|
+
deriveActionSchemas,
|
|
1003
|
+
generateClientPackage,
|
|
1004
|
+
isValidCronSyntax,
|
|
1005
|
+
jsonSchemaToTsType,
|
|
1006
|
+
pascalCase,
|
|
1007
|
+
validateActionDecls,
|
|
1008
|
+
validateManifest
|
|
1009
|
+
};
|
|
1010
|
+
//# sourceMappingURL=testing.js.map
|