@odla-ai/brand 0.2.0 → 0.3.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/README.md +113 -5
- package/dist/{index-bj73h3qo.d.cts → index-CRl3IXHB.d.cts} +12 -168
- package/dist/{index-bj73h3qo.d.ts → index-CRl3IXHB.d.ts} +12 -168
- package/dist/index.cjs +2104 -331
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +655 -75
- package/dist/index.d.ts +655 -75
- package/dist/index.js +2105 -332
- package/dist/index.js.map +1 -1
- package/dist/tokens/index.d.cts +1 -1
- package/dist/tokens/index.d.ts +1 -1
- package/package.json +1 -1
package/dist/index.cjs
CHANGED
|
@@ -23,17 +23,23 @@ __export(src_exports, {
|
|
|
23
23
|
ASSET_CONTENT_TYPES: () => ASSET_CONTENT_TYPES,
|
|
24
24
|
ASSET_KINDS: () => ASSET_KINDS,
|
|
25
25
|
BOOK_STATUSES: () => BOOK_STATUSES,
|
|
26
|
+
BRAND_AGENT_PROFILE: () => BRAND_AGENT_PROFILE,
|
|
26
27
|
BRAND_CHART_TOKENS: () => BRAND_CHART_TOKENS,
|
|
27
28
|
BRAND_CHAT_TOKENS: () => BRAND_CHAT_TOKENS,
|
|
28
29
|
BRAND_DERIVED_TOKENS: () => BRAND_DERIVED_TOKENS,
|
|
30
|
+
BRAND_DISCUSSION_REFERENCE_KINDS: () => BRAND_DISCUSSION_REFERENCE_KINDS,
|
|
29
31
|
BRAND_EMITTED_TOKENS: () => BRAND_EMITTED_TOKENS,
|
|
30
32
|
BRAND_INSTRUCTIONS: () => BRAND_INSTRUCTIONS,
|
|
31
33
|
BRAND_NS: () => BRAND_NS,
|
|
32
34
|
BRAND_REQUIRED_TOKENS: () => BRAND_REQUIRED_TOKENS,
|
|
33
35
|
BRAND_RULES: () => BRAND_RULES,
|
|
34
36
|
BRAND_SCHEMA: () => BRAND_SCHEMA,
|
|
37
|
+
BrandConflictError: () => BrandConflictError,
|
|
38
|
+
BrandForbiddenError: () => BrandForbiddenError,
|
|
39
|
+
BrandGoneError: () => BrandGoneError,
|
|
35
40
|
BrandInputError: () => BrandInputError,
|
|
36
41
|
BrandNotFoundError: () => BrandNotFoundError,
|
|
42
|
+
BrandReviewStateChangedError: () => BrandReviewStateChangedError,
|
|
37
43
|
CHART_DELTA_MIN: () => CHART_DELTA_MIN,
|
|
38
44
|
CHAT_MESSAGE_NS: () => CHAT_MESSAGE_NS,
|
|
39
45
|
CSS_NAMED_COLORS: () => CSS_NAMED_COLORS,
|
|
@@ -62,15 +68,22 @@ __export(src_exports, {
|
|
|
62
68
|
assertSectionContent: () => assertSectionContent,
|
|
63
69
|
assertSwatches: () => assertSwatches,
|
|
64
70
|
assetTools: () => assetTools,
|
|
71
|
+
attachedBrandAssetIds: () => attachedBrandAssetIds,
|
|
65
72
|
audienceFanoutOps: () => audienceFanoutOps,
|
|
66
73
|
base64FromBytes: () => base64FromBytes,
|
|
74
|
+
beginAssetDeleteOps: () => beginAssetDeleteOps,
|
|
67
75
|
bookForChannel: () => bookForChannel,
|
|
68
76
|
bookTools: () => bookTools,
|
|
77
|
+
brandAuthorityRef: () => brandAuthorityRef,
|
|
69
78
|
brandBotTrigger: () => brandBotTrigger,
|
|
79
|
+
brandDiscussionReferenceHref: () => brandDiscussionReferenceHref,
|
|
80
|
+
brandDiscussionReferenceId: () => brandDiscussionReferenceId,
|
|
70
81
|
brandInputFor: () => brandInputFor,
|
|
71
82
|
brandIntegration: () => brandIntegration,
|
|
83
|
+
brandJsonDigest: () => brandJsonDigest,
|
|
72
84
|
brandRules: () => brandRules,
|
|
73
85
|
brandSkill: () => brandSkill,
|
|
86
|
+
canonicalBrandJson: () => canonicalBrandJson,
|
|
74
87
|
capString: () => capString,
|
|
75
88
|
capStringArray: () => capStringArray,
|
|
76
89
|
clamp01: () => clamp01,
|
|
@@ -89,9 +102,13 @@ __export(src_exports, {
|
|
|
89
102
|
deriveDarkTokens: () => deriveDarkTokens,
|
|
90
103
|
derivePalette: () => derivePalette,
|
|
91
104
|
dispatchBrandTurn: () => dispatchBrandTurn,
|
|
105
|
+
finishAssetDeleteOps: () => finishAssetDeleteOps,
|
|
106
|
+
formatBrandDiscussionReference: () => formatBrandDiscussionReference,
|
|
92
107
|
hexToOklch: () => hexToOklch,
|
|
93
108
|
hslToRgb: () => hslToRgb,
|
|
94
109
|
inSrgbGamut: () => inSrgbGamut,
|
|
110
|
+
isBoundedBrandJson: () => isBoundedBrandJson,
|
|
111
|
+
isBrandHumanAuthorityConsumption: () => isBrandHumanAuthorityConsumption,
|
|
95
112
|
linearToSrgb: () => linearToSrgb,
|
|
96
113
|
mapPaletteToTokens: () => mapPaletteToTokens,
|
|
97
114
|
meetsAA: () => meetsAA,
|
|
@@ -104,10 +121,13 @@ __export(src_exports, {
|
|
|
104
121
|
oklchToHex: () => oklchToHex,
|
|
105
122
|
oklchToOklab: () => oklchToOklab,
|
|
106
123
|
paletteTools: () => paletteTools,
|
|
124
|
+
parseBrandDiscussionReference: () => parseBrandDiscussionReference,
|
|
107
125
|
parseBrandDispatch: () => parseBrandDispatch,
|
|
108
126
|
parseHex: () => parseHex,
|
|
109
127
|
pickTextOn: () => pickTextOn,
|
|
128
|
+
proposalReviewSnapshot: () => proposalReviewSnapshot,
|
|
110
129
|
proposePaletteOps: () => proposePaletteOps,
|
|
130
|
+
proposeSectionOps: () => proposeSectionOps,
|
|
111
131
|
readTools: () => readTools,
|
|
112
132
|
recordAnalysisOps: () => recordAnalysisOps,
|
|
113
133
|
rejectProposalOps: () => rejectProposalOps,
|
|
@@ -125,10 +145,10 @@ __export(src_exports, {
|
|
|
125
145
|
tetradic: () => tetradic,
|
|
126
146
|
tintShadeRamp: () => tintShadeRamp,
|
|
127
147
|
toHex: () => toHex,
|
|
128
|
-
tombstoneAssetOps: () => tombstoneAssetOps,
|
|
129
148
|
triadic: () => triadic,
|
|
130
149
|
updateBookOps: () => updateBookOps,
|
|
131
|
-
upsertSectionOps: () => upsertSectionOps
|
|
150
|
+
upsertSectionOps: () => upsertSectionOps,
|
|
151
|
+
verifyBrandApprovalReceipt: () => verifyBrandApprovalReceipt
|
|
132
152
|
});
|
|
133
153
|
module.exports = __toCommonJS(src_exports);
|
|
134
154
|
|
|
@@ -138,6 +158,7 @@ var BRAND_NS = {
|
|
|
138
158
|
section: "brand_section",
|
|
139
159
|
palette: "brand_palette",
|
|
140
160
|
proposal: "brand_proposal",
|
|
161
|
+
approvalReceipt: "brand_approval_receipt",
|
|
141
162
|
asset: "brand_asset"
|
|
142
163
|
};
|
|
143
164
|
var BOOK_STATUSES = ["draft", "active", "archived"];
|
|
@@ -145,9 +166,9 @@ var SECTION_KINDS = ["palette", "typography", "voice", "logo", "imagery"];
|
|
|
145
166
|
var SECTION_STATUSES = ["draft", "approved"];
|
|
146
167
|
var PALETTE_STATUSES = ["active", "archived"];
|
|
147
168
|
var PALETTE_SOURCES = ["extracted", "derived", "manual"];
|
|
148
|
-
var PROPOSAL_KINDS =
|
|
169
|
+
var PROPOSAL_KINDS = SECTION_KINDS;
|
|
149
170
|
var PROPOSAL_STATUSES = ["open", "accepted", "rejected", "superseded"];
|
|
150
|
-
var ASSET_KINDS = ["logo", "wordmark", "inspiration", "document", "
|
|
171
|
+
var ASSET_KINDS = ["logo", "wordmark", "inspiration", "document", "other"];
|
|
151
172
|
var SWATCH_ROLES = [
|
|
152
173
|
"primary",
|
|
153
174
|
"secondary",
|
|
@@ -163,6 +184,25 @@ var SWATCH_ROLES = [
|
|
|
163
184
|
"custom"
|
|
164
185
|
];
|
|
165
186
|
|
|
187
|
+
// src/agent-profile.ts
|
|
188
|
+
var BRAND_AGENT_PROFILE = {
|
|
189
|
+
version: 1,
|
|
190
|
+
projectCapabilities: ["brand.read", "brand.edit"],
|
|
191
|
+
semanticOperations: [
|
|
192
|
+
"brand.proposal.create",
|
|
193
|
+
"brand.asset.analysis.record",
|
|
194
|
+
"brand.asset.content.read"
|
|
195
|
+
],
|
|
196
|
+
rawBrandWrites: false,
|
|
197
|
+
rawFileOperations: false,
|
|
198
|
+
approval: {
|
|
199
|
+
principalKind: "human",
|
|
200
|
+
projectCapability: "brand.approve",
|
|
201
|
+
capability: "brand.proposal.resolve",
|
|
202
|
+
exactAction: true
|
|
203
|
+
}
|
|
204
|
+
};
|
|
205
|
+
|
|
166
206
|
// src/errors.ts
|
|
167
207
|
var BrandInputError = class extends Error {
|
|
168
208
|
fields;
|
|
@@ -178,6 +218,31 @@ var BrandNotFoundError = class extends Error {
|
|
|
178
218
|
this.name = "BrandNotFoundError";
|
|
179
219
|
}
|
|
180
220
|
};
|
|
221
|
+
var BrandGoneError = class extends Error {
|
|
222
|
+
constructor(message = "brand authority or resource is no longer available") {
|
|
223
|
+
super(message);
|
|
224
|
+
this.name = "BrandGoneError";
|
|
225
|
+
}
|
|
226
|
+
};
|
|
227
|
+
var BrandForbiddenError = class extends Error {
|
|
228
|
+
constructor(message = "forbidden") {
|
|
229
|
+
super(message);
|
|
230
|
+
this.name = "BrandForbiddenError";
|
|
231
|
+
}
|
|
232
|
+
};
|
|
233
|
+
var BrandConflictError = class extends Error {
|
|
234
|
+
constructor(message) {
|
|
235
|
+
super(message);
|
|
236
|
+
this.name = "BrandConflictError";
|
|
237
|
+
}
|
|
238
|
+
};
|
|
239
|
+
var BrandReviewStateChangedError = class extends BrandConflictError {
|
|
240
|
+
code = "brand_review_state_changed";
|
|
241
|
+
constructor(message = "proposal or live brand dependencies changed; review and approve again") {
|
|
242
|
+
super(message);
|
|
243
|
+
this.name = "BrandReviewStateChangedError";
|
|
244
|
+
}
|
|
245
|
+
};
|
|
181
246
|
|
|
182
247
|
// src/deps.ts
|
|
183
248
|
async function defaultFetchBytes(url) {
|
|
@@ -213,17 +278,21 @@ var BRAND_SCHEMA = {
|
|
|
213
278
|
[BRAND_NS.book]: {
|
|
214
279
|
attrs: {
|
|
215
280
|
id: uniq("string"),
|
|
281
|
+
version: a("number"),
|
|
216
282
|
slug: uniq("string"),
|
|
217
283
|
name: idx("string"),
|
|
218
284
|
status: idx("string"),
|
|
219
285
|
// draft | active | archived
|
|
220
286
|
ownerId: idx("string"),
|
|
287
|
+
createdAuthorityRef: idx("string"),
|
|
288
|
+
rosterAuthorityRef: opt("string"),
|
|
221
289
|
memberIds: a("json"),
|
|
222
290
|
// the auth roster, incl. the bot agent id
|
|
223
|
-
channelId:
|
|
291
|
+
channelId: a("string", { unique: true, indexed: true, optional: true }),
|
|
224
292
|
activePaletteId: opt("string"),
|
|
293
|
+
activationRevision: a("number"),
|
|
225
294
|
tokens: opt("json"),
|
|
226
|
-
//
|
|
295
|
+
// maps + complete palette/typography receipt provenance
|
|
227
296
|
summary: opt("string"),
|
|
228
297
|
createdAt: idx("date"),
|
|
229
298
|
updatedAt: idx("date")
|
|
@@ -241,7 +310,9 @@ var BRAND_SCHEMA = {
|
|
|
241
310
|
content: a("json"),
|
|
242
311
|
audience: a("json"),
|
|
243
312
|
updatedBy: a("string"),
|
|
244
|
-
updatedAt: idx("date")
|
|
313
|
+
updatedAt: idx("date"),
|
|
314
|
+
approvalReceiptId: opt("string"),
|
|
315
|
+
approvalActionDigest: opt("string")
|
|
245
316
|
}
|
|
246
317
|
},
|
|
247
318
|
[BRAND_NS.palette]: {
|
|
@@ -257,7 +328,9 @@ var BRAND_SCHEMA = {
|
|
|
257
328
|
source: a("string"),
|
|
258
329
|
// extracted | derived | manual
|
|
259
330
|
rationale: opt("string"),
|
|
260
|
-
proposalId:
|
|
331
|
+
proposalId: idx("string"),
|
|
332
|
+
approvalReceiptId: a("string"),
|
|
333
|
+
approvalActionDigest: a("string"),
|
|
261
334
|
audience: a("json"),
|
|
262
335
|
createdAt: idx("date"),
|
|
263
336
|
updatedAt: idx("date")
|
|
@@ -273,14 +346,45 @@ var BRAND_SCHEMA = {
|
|
|
273
346
|
// open | accepted | rejected | superseded
|
|
274
347
|
payload: a("json"),
|
|
275
348
|
rationale: a("string"),
|
|
276
|
-
|
|
277
|
-
|
|
349
|
+
provenance: a("json"),
|
|
350
|
+
// exact source asset/message/turn ids (nullable asset)
|
|
351
|
+
reviewDigest: idx("string"),
|
|
278
352
|
audience: a("json"),
|
|
279
353
|
createdBy: a("string"),
|
|
354
|
+
createdAuthorityRef: a("string"),
|
|
280
355
|
createdAt: idx("date"),
|
|
281
356
|
resolvedBy: opt("string"),
|
|
282
357
|
resolvedAt: opt("date"),
|
|
283
|
-
resolutionNote: opt("string")
|
|
358
|
+
resolutionNote: opt("string"),
|
|
359
|
+
approvedBy: opt("string"),
|
|
360
|
+
appliedBy: opt("string"),
|
|
361
|
+
resolutionReceiptId: opt("string"),
|
|
362
|
+
resolutionActionDigest: opt("string")
|
|
363
|
+
}
|
|
364
|
+
},
|
|
365
|
+
[BRAND_NS.approvalReceipt]: {
|
|
366
|
+
attrs: {
|
|
367
|
+
id: uniq("string"),
|
|
368
|
+
version: a("number"),
|
|
369
|
+
mutationKey: uniq("string"),
|
|
370
|
+
bookId: idx("string"),
|
|
371
|
+
proposalId: idx("string"),
|
|
372
|
+
resolution: idx("string"),
|
|
373
|
+
// accepted | rejected
|
|
374
|
+
paletteId: opt("string"),
|
|
375
|
+
resolutionNote: opt("string"),
|
|
376
|
+
reviewedProposal: a("json"),
|
|
377
|
+
actionDigest: idx("string"),
|
|
378
|
+
decisionBinding: a("json"),
|
|
379
|
+
approvedBy: idx("string"),
|
|
380
|
+
approvedByKind: a("string"),
|
|
381
|
+
appliedBy: idx("string"),
|
|
382
|
+
appliedByKind: a("string"),
|
|
383
|
+
authorityRef: idx("string"),
|
|
384
|
+
authorityCapability: a("string"),
|
|
385
|
+
authorityConsumption: a("json"),
|
|
386
|
+
createdAt: idx("date"),
|
|
387
|
+
receiptDigest: idx("string")
|
|
284
388
|
}
|
|
285
389
|
},
|
|
286
390
|
[BRAND_NS.asset]: {
|
|
@@ -288,59 +392,94 @@ var BRAND_SCHEMA = {
|
|
|
288
392
|
id: uniq("string"),
|
|
289
393
|
bookId: idx("string"),
|
|
290
394
|
kind: idx("string"),
|
|
291
|
-
// logo | wordmark | inspiration | document |
|
|
395
|
+
// logo | wordmark | inspiration | document | other
|
|
292
396
|
path: idx("string"),
|
|
293
|
-
|
|
397
|
+
storageObjectId: idx("string"),
|
|
398
|
+
contentDigest: idx("string"),
|
|
294
399
|
contentType: a("string"),
|
|
295
400
|
size: a("number"),
|
|
401
|
+
status: idx("string"),
|
|
402
|
+
// live | deleting | deleted
|
|
296
403
|
title: opt("string"),
|
|
297
404
|
analysis: opt("json"),
|
|
298
405
|
// { description, dominantColors: hex[], tags }
|
|
299
406
|
analyzedAt: opt("date"),
|
|
407
|
+
analysisRevision: a("number"),
|
|
408
|
+
analysisDigest: opt("string"),
|
|
409
|
+
analyzedBy: opt("string"),
|
|
410
|
+
analyzedAuthorityRef: opt("string"),
|
|
300
411
|
audience: a("json"),
|
|
301
412
|
uploadedBy: a("string"),
|
|
413
|
+
uploadedAuthorityRef: a("string"),
|
|
302
414
|
createdAt: idx("date"),
|
|
303
|
-
deletedAt: opt("date")
|
|
415
|
+
deletedAt: opt("date"),
|
|
304
416
|
// tombstone — asset rows are never row-deleted
|
|
417
|
+
deletedBy: opt("string"),
|
|
418
|
+
deletedAuthorityRef: opt("string")
|
|
305
419
|
}
|
|
306
420
|
}
|
|
307
421
|
},
|
|
308
|
-
links: {
|
|
422
|
+
links: {
|
|
423
|
+
brandSectionBook: {
|
|
424
|
+
forward: { on: BRAND_NS.section, has: "one", label: "book" },
|
|
425
|
+
reverse: { on: BRAND_NS.book, has: "many", label: "sections" }
|
|
426
|
+
},
|
|
427
|
+
brandPaletteBook: {
|
|
428
|
+
forward: { on: BRAND_NS.palette, has: "one", label: "book" },
|
|
429
|
+
reverse: { on: BRAND_NS.book, has: "many", label: "palettes" }
|
|
430
|
+
},
|
|
431
|
+
brandProposalBook: {
|
|
432
|
+
forward: { on: BRAND_NS.proposal, has: "one", label: "book" },
|
|
433
|
+
reverse: { on: BRAND_NS.book, has: "many", label: "proposals" }
|
|
434
|
+
},
|
|
435
|
+
brandApprovalReceiptBook: {
|
|
436
|
+
forward: { on: BRAND_NS.approvalReceipt, has: "one", label: "book" },
|
|
437
|
+
reverse: { on: BRAND_NS.book, has: "many", label: "approvalReceipts" }
|
|
438
|
+
},
|
|
439
|
+
brandAssetBook: {
|
|
440
|
+
forward: { on: BRAND_NS.asset, has: "one", label: "book" },
|
|
441
|
+
reverse: { on: BRAND_NS.book, has: "many", label: "assets" }
|
|
442
|
+
}
|
|
443
|
+
}
|
|
309
444
|
};
|
|
310
445
|
|
|
311
446
|
// src/rules.ts
|
|
312
|
-
var
|
|
447
|
+
var CURRENT_BOOK_MEMBER = "ref('book.memberIds').exists(members, auth.id in members)";
|
|
313
448
|
var BRAND_RULES = {
|
|
314
449
|
[BRAND_NS.book]: {
|
|
315
450
|
view: "auth.id in data.memberIds",
|
|
316
|
-
//
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
451
|
+
// Every mutation is worker-routed: CEL cannot distinguish an update from
|
|
452
|
+
// a retract, so effect-bearing fields must never be browser-writable.
|
|
453
|
+
create: "false",
|
|
454
|
+
update: "false",
|
|
455
|
+
delete: "false"
|
|
320
456
|
},
|
|
321
457
|
[BRAND_NS.section]: {
|
|
322
|
-
view:
|
|
323
|
-
create:
|
|
324
|
-
update:
|
|
458
|
+
view: CURRENT_BOOK_MEMBER,
|
|
459
|
+
create: "false",
|
|
460
|
+
update: "false",
|
|
325
461
|
delete: "false"
|
|
326
|
-
// sections are upserted in place, never removed
|
|
327
462
|
},
|
|
328
463
|
[BRAND_NS.palette]: {
|
|
329
|
-
view:
|
|
330
|
-
create:
|
|
331
|
-
update:
|
|
464
|
+
view: CURRENT_BOOK_MEMBER,
|
|
465
|
+
create: "false",
|
|
466
|
+
update: "false",
|
|
332
467
|
delete: "false"
|
|
333
|
-
// archive via status, keep provenance
|
|
334
468
|
},
|
|
335
469
|
[BRAND_NS.proposal]: {
|
|
336
|
-
view:
|
|
337
|
-
create:
|
|
338
|
-
update:
|
|
470
|
+
view: CURRENT_BOOK_MEMBER,
|
|
471
|
+
create: "false",
|
|
472
|
+
update: "false",
|
|
473
|
+
delete: "false"
|
|
474
|
+
},
|
|
475
|
+
[BRAND_NS.approvalReceipt]: {
|
|
476
|
+
view: CURRENT_BOOK_MEMBER,
|
|
477
|
+
create: "false",
|
|
478
|
+
update: "false",
|
|
339
479
|
delete: "false"
|
|
340
|
-
// resolution history is the audit trail
|
|
341
480
|
},
|
|
342
481
|
[BRAND_NS.asset]: {
|
|
343
|
-
view:
|
|
482
|
+
view: CURRENT_BOOK_MEMBER,
|
|
344
483
|
create: "false",
|
|
345
484
|
update: "false",
|
|
346
485
|
delete: "false"
|
|
@@ -357,7 +496,6 @@ var ASSET_CONTENT_TYPES = /* @__PURE__ */ new Set([
|
|
|
357
496
|
"image/jpeg",
|
|
358
497
|
"image/gif",
|
|
359
498
|
"image/webp",
|
|
360
|
-
"image/svg+xml",
|
|
361
499
|
"application/pdf"
|
|
362
500
|
]);
|
|
363
501
|
var HEX_RGB = /^#[0-9a-f]{3}$/;
|
|
@@ -474,8 +612,11 @@ function assertSectionContent(kind, content) {
|
|
|
474
612
|
}
|
|
475
613
|
function safeFileName(name) {
|
|
476
614
|
if (typeof name !== "string") throw new BrandInputError("file name must be a string");
|
|
477
|
-
|
|
478
|
-
|
|
615
|
+
if (/[/\\?#%\u0000-\u001f\u007f]/.test(name))
|
|
616
|
+
throw new BrandInputError("file name must not contain path or URL delimiter characters");
|
|
617
|
+
const cleaned = name.trim().replace(/^\.+/, "");
|
|
618
|
+
if (cleaned === "" || cleaned === "." || cleaned === "..")
|
|
619
|
+
throw new BrandInputError("file name is empty or a dot segment after sanitizing");
|
|
479
620
|
return cleaned.slice(0, 120);
|
|
480
621
|
}
|
|
481
622
|
function assertAssetContentType(value) {
|
|
@@ -500,6 +641,237 @@ function assertAnalysis(value) {
|
|
|
500
641
|
};
|
|
501
642
|
}
|
|
502
643
|
|
|
644
|
+
// src/review-json.ts
|
|
645
|
+
var record = (value) => typeof value === "object" && value !== null && !Array.isArray(value) && (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null);
|
|
646
|
+
function isBoundedBrandJson(root, limits = {}) {
|
|
647
|
+
const maxDepth = limits.maxDepth ?? 12;
|
|
648
|
+
const maxNodes = limits.maxNodes ?? 2048;
|
|
649
|
+
const maxBytes = limits.maxBytes ?? 32 * 1024;
|
|
650
|
+
const stack = [{
|
|
651
|
+
value: root,
|
|
652
|
+
depth: 0
|
|
653
|
+
}];
|
|
654
|
+
const seen = /* @__PURE__ */ new Set();
|
|
655
|
+
let nodes = 0;
|
|
656
|
+
while (stack.length) {
|
|
657
|
+
const { value, depth } = stack.pop();
|
|
658
|
+
if (++nodes > maxNodes || depth > maxDepth) return false;
|
|
659
|
+
if (value === null || typeof value === "string" || typeof value === "boolean") continue;
|
|
660
|
+
if (typeof value === "number") {
|
|
661
|
+
if (!Number.isFinite(value)) return false;
|
|
662
|
+
continue;
|
|
663
|
+
}
|
|
664
|
+
if (typeof value !== "object" || seen.has(value)) return false;
|
|
665
|
+
seen.add(value);
|
|
666
|
+
if (Array.isArray(value)) {
|
|
667
|
+
for (const child of value)
|
|
668
|
+
stack.push({ value: child, depth: depth + 1 });
|
|
669
|
+
} else if (record(value)) {
|
|
670
|
+
for (const child of Object.values(value))
|
|
671
|
+
stack.push({ value: child, depth: depth + 1 });
|
|
672
|
+
} else {
|
|
673
|
+
return false;
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
try {
|
|
677
|
+
return new TextEncoder().encode(JSON.stringify(root)).byteLength <= maxBytes;
|
|
678
|
+
} catch {
|
|
679
|
+
return false;
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
function canonical(value) {
|
|
683
|
+
if (value === null || typeof value !== "object") return JSON.stringify(value);
|
|
684
|
+
if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
|
|
685
|
+
const row = value;
|
|
686
|
+
return `{${Object.keys(row).sort().map((key) => `${JSON.stringify(key)}:${canonical(row[key])}`).join(",")}}`;
|
|
687
|
+
}
|
|
688
|
+
function canonicalBrandJson(value) {
|
|
689
|
+
if (!isBoundedBrandJson(value, {
|
|
690
|
+
maxDepth: 20,
|
|
691
|
+
maxNodes: 8192,
|
|
692
|
+
maxBytes: 128 * 1024
|
|
693
|
+
})) throw new TypeError("brand digest input must be bounded finite JSON");
|
|
694
|
+
return canonical(value);
|
|
695
|
+
}
|
|
696
|
+
async function brandJsonDigest(value) {
|
|
697
|
+
const bytes = await crypto.subtle.digest(
|
|
698
|
+
"SHA-256",
|
|
699
|
+
new TextEncoder().encode(canonicalBrandJson(value))
|
|
700
|
+
);
|
|
701
|
+
return `sha256:${[...new Uint8Array(bytes)].map((byte) => byte.toString(16).padStart(2, "0")).join("")}`;
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
// src/review.ts
|
|
705
|
+
var DIGEST = /^sha256:[0-9a-f]{64}$/;
|
|
706
|
+
var record2 = (value) => typeof value === "object" && value !== null && !Array.isArray(value) && (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null);
|
|
707
|
+
var exactKeys = (value, required, optional = []) => {
|
|
708
|
+
const allowed = /* @__PURE__ */ new Set([...required, ...optional]);
|
|
709
|
+
return required.every((key) => Object.hasOwn(value, key)) && Object.keys(value).every((key) => allowed.has(key));
|
|
710
|
+
};
|
|
711
|
+
var boundedString = (value, max = 200) => typeof value === "string" && value.length > 0 && value.length <= max;
|
|
712
|
+
var safeTime = (value) => Number.isSafeInteger(value) && value >= 0;
|
|
713
|
+
function brandAuthorityRef(consumption) {
|
|
714
|
+
return `authority:${consumption.grantId}:v${consumption.grantVersion}`;
|
|
715
|
+
}
|
|
716
|
+
var SNAPSHOT_REQUIRED = [
|
|
717
|
+
"id",
|
|
718
|
+
"bookId",
|
|
719
|
+
"kind",
|
|
720
|
+
"status",
|
|
721
|
+
"payload",
|
|
722
|
+
"rationale",
|
|
723
|
+
"provenance",
|
|
724
|
+
"reviewDigest",
|
|
725
|
+
"audience",
|
|
726
|
+
"createdBy",
|
|
727
|
+
"createdAuthorityRef",
|
|
728
|
+
"createdAt"
|
|
729
|
+
];
|
|
730
|
+
function validSnapshotShape(value) {
|
|
731
|
+
if (!record2(value) || !exactKeys(value, SNAPSHOT_REQUIRED)) return false;
|
|
732
|
+
const provenance = value.provenance;
|
|
733
|
+
return boundedString(value.id) && boundedString(value.bookId) && typeof value.kind === "string" && PROPOSAL_KINDS.includes(value.kind) && value.status === "open" && record2(value.payload) && boundedString(value.rationale, 2e3) && record2(provenance) && exactKeys(provenance, [
|
|
734
|
+
"sourceAssetId",
|
|
735
|
+
"sourceAsset",
|
|
736
|
+
"messageId",
|
|
737
|
+
"turnId",
|
|
738
|
+
"taintLabels"
|
|
739
|
+
]) && (provenance.sourceAssetId === null || boundedString(provenance.sourceAssetId)) && (provenance.sourceAsset === null || record2(provenance.sourceAsset) && exactKeys(provenance.sourceAsset, [
|
|
740
|
+
"assetId",
|
|
741
|
+
"contentDigest",
|
|
742
|
+
"objectEtag",
|
|
743
|
+
"objectSize",
|
|
744
|
+
"pathDigest",
|
|
745
|
+
"contentType",
|
|
746
|
+
"analysisRevision",
|
|
747
|
+
"analysisDigest"
|
|
748
|
+
]) && boundedString(provenance.sourceAsset.assetId) && typeof provenance.sourceAsset.contentDigest === "string" && DIGEST.test(provenance.sourceAsset.contentDigest) && boundedString(provenance.sourceAsset.objectEtag) && Number.isSafeInteger(provenance.sourceAsset.objectSize) && provenance.sourceAsset.objectSize > 0 && typeof provenance.sourceAsset.pathDigest === "string" && DIGEST.test(provenance.sourceAsset.pathDigest) && (provenance.sourceAsset.contentType === null || boundedString(provenance.sourceAsset.contentType, 160)) && (provenance.sourceAsset.analysisRevision === null || Number.isSafeInteger(provenance.sourceAsset.analysisRevision) && provenance.sourceAsset.analysisRevision >= 0) && (provenance.sourceAsset.analysisDigest === null || typeof provenance.sourceAsset.analysisDigest === "string" && DIGEST.test(provenance.sourceAsset.analysisDigest))) && (provenance.sourceAssetId === null && provenance.sourceAsset === null || provenance.sourceAssetId !== null && provenance.sourceAsset !== null && provenance.sourceAsset.assetId === provenance.sourceAssetId) && (provenance.messageId === null || boundedString(provenance.messageId)) && (provenance.turnId === null || boundedString(provenance.turnId)) && Array.isArray(provenance.taintLabels) && provenance.taintLabels.length <= 16 && provenance.taintLabels.every((label) => boundedString(label, 120)) && new Set(provenance.taintLabels).size === provenance.taintLabels.length && typeof value.reviewDigest === "string" && DIGEST.test(value.reviewDigest) && Array.isArray(value.audience) && value.audience.length > 0 && value.audience.length <= 100 && value.audience.every((id) => boundedString(id)) && new Set(value.audience).size === value.audience.length && boundedString(value.createdBy) && boundedString(value.createdAuthorityRef) && safeTime(value.createdAt);
|
|
749
|
+
}
|
|
750
|
+
var CONSUMPTION_REQUIRED = [
|
|
751
|
+
"id",
|
|
752
|
+
"grantId",
|
|
753
|
+
"grantVersion",
|
|
754
|
+
"useNumber",
|
|
755
|
+
"actorPrincipalId",
|
|
756
|
+
"actorKind",
|
|
757
|
+
"credentialId",
|
|
758
|
+
"credentialKind",
|
|
759
|
+
"appId",
|
|
760
|
+
"appIncarnation",
|
|
761
|
+
"capability",
|
|
762
|
+
"projectCapability",
|
|
763
|
+
"effect",
|
|
764
|
+
"actionDigest",
|
|
765
|
+
"resourceDigest",
|
|
766
|
+
"constraintEvidence",
|
|
767
|
+
"consumptionIdempotencyKey",
|
|
768
|
+
"requestDigest",
|
|
769
|
+
"consumedAt"
|
|
770
|
+
];
|
|
771
|
+
function isBrandHumanAuthorityConsumption(value) {
|
|
772
|
+
if (!record2(value) || !exactKeys(value, CONSUMPTION_REQUIRED)) return false;
|
|
773
|
+
return boundedString(value.id) && boundedString(value.grantId) && Number.isSafeInteger(value.grantVersion) && value.grantVersion > 0 && Number.isSafeInteger(value.useNumber) && value.useNumber > 0 && boundedString(value.actorPrincipalId) && value.actorKind === "human" && boundedString(value.credentialId) && value.credentialKind === "clerk" && boundedString(value.appId) && typeof value.appIncarnation === "string" && /^[a-f0-9]{32}$/.test(value.appIncarnation) && value.capability === "brand.proposal.resolve" && value.projectCapability === "brand.approve" && value.effect === "internal" && typeof value.actionDigest === "string" && DIGEST.test(value.actionDigest) && typeof value.resourceDigest === "string" && DIGEST.test(value.resourceDigest) && record2(value.constraintEvidence) && boundedString(value.consumptionIdempotencyKey) && typeof value.requestDigest === "string" && DIGEST.test(value.requestDigest) && safeTime(value.consumedAt);
|
|
774
|
+
}
|
|
775
|
+
var RECEIPT_REQUIRED = [
|
|
776
|
+
"version",
|
|
777
|
+
"id",
|
|
778
|
+
"mutationKey",
|
|
779
|
+
"bookId",
|
|
780
|
+
"proposalId",
|
|
781
|
+
"resolution",
|
|
782
|
+
"reviewedProposal",
|
|
783
|
+
"actionDigest",
|
|
784
|
+
"decisionBinding",
|
|
785
|
+
"approvedBy",
|
|
786
|
+
"approvedByKind",
|
|
787
|
+
"appliedBy",
|
|
788
|
+
"appliedByKind",
|
|
789
|
+
"authorityRef",
|
|
790
|
+
"authorityCapability",
|
|
791
|
+
"authorityConsumption",
|
|
792
|
+
"createdAt",
|
|
793
|
+
"receiptDigest"
|
|
794
|
+
];
|
|
795
|
+
async function verifyBrandApprovalReceipt(receipt2) {
|
|
796
|
+
try {
|
|
797
|
+
if (!isBoundedBrandJson(receipt2, { maxDepth: 14, maxNodes: 2500, maxBytes: 48 * 1024 }))
|
|
798
|
+
return false;
|
|
799
|
+
if (!record2(receipt2) || !exactKeys(receipt2, RECEIPT_REQUIRED, ["paletteId", "resolutionNote"]))
|
|
800
|
+
return false;
|
|
801
|
+
const binding = receipt2.decisionBinding;
|
|
802
|
+
if (receipt2.version !== 1 || !boundedString(receipt2.id) || !boundedString(receipt2.mutationKey) || !boundedString(receipt2.bookId) || !boundedString(receipt2.proposalId) || receipt2.resolution !== "accepted" && receipt2.resolution !== "rejected" || !validSnapshotShape(receipt2.reviewedProposal) || typeof receipt2.actionDigest !== "string" || !DIGEST.test(receipt2.actionDigest) || !record2(binding) || !exactKeys(binding, [
|
|
803
|
+
"version",
|
|
804
|
+
"bookVersion",
|
|
805
|
+
"activationRevision",
|
|
806
|
+
"activePaletteId",
|
|
807
|
+
"memberIdsDigest",
|
|
808
|
+
"activePaletteDigest",
|
|
809
|
+
"typographyDigest",
|
|
810
|
+
"sourceAssetDigest",
|
|
811
|
+
"effectDigest"
|
|
812
|
+
]) || binding.version !== 1 || !Number.isSafeInteger(binding.bookVersion) || binding.bookVersion < 1 || !Number.isSafeInteger(binding.activationRevision) || binding.activationRevision < 0 || binding.activePaletteId !== null && !boundedString(binding.activePaletteId) || typeof binding.memberIdsDigest !== "string" || !DIGEST.test(binding.memberIdsDigest) || binding.activePaletteDigest !== null && (typeof binding.activePaletteDigest !== "string" || !DIGEST.test(binding.activePaletteDigest)) || binding.typographyDigest !== null && (typeof binding.typographyDigest !== "string" || !DIGEST.test(binding.typographyDigest)) || binding.sourceAssetDigest !== null && (typeof binding.sourceAssetDigest !== "string" || !DIGEST.test(binding.sourceAssetDigest)) || typeof binding.effectDigest !== "string" || !DIGEST.test(binding.effectDigest) || !boundedString(receipt2.approvedBy) || receipt2.approvedByKind !== "human" || !boundedString(receipt2.appliedBy) || receipt2.appliedByKind !== "human" || !boundedString(receipt2.authorityRef) || receipt2.authorityCapability !== "brand.approve" || !isBrandHumanAuthorityConsumption(receipt2.authorityConsumption) || !safeTime(receipt2.createdAt) || typeof receipt2.receiptDigest !== "string" || !DIGEST.test(receipt2.receiptDigest) || receipt2.paletteId !== void 0 && !boundedString(receipt2.paletteId) || receipt2.resolutionNote !== void 0 && !boundedString(receipt2.resolutionNote, 1e3)) return false;
|
|
813
|
+
const reviewed = receipt2.reviewedProposal;
|
|
814
|
+
const authority = receipt2.authorityConsumption;
|
|
815
|
+
if (reviewed.id !== receipt2.proposalId || reviewed.bookId !== receipt2.bookId || receipt2.approvedBy !== receipt2.appliedBy || authority.actorPrincipalId !== receipt2.approvedBy || authority.actionDigest !== receipt2.actionDigest || authority.consumptionIdempotencyKey !== receipt2.mutationKey || receipt2.authorityRef !== brandAuthorityRef(authority) || authority.consumedAt > receipt2.createdAt || receipt2.resolution === "rejected" && receipt2.paletteId !== void 0 || receipt2.resolution === "accepted" && reviewed.kind === "palette" && !receipt2.paletteId || reviewed.kind !== "palette" && receipt2.paletteId !== void 0) return false;
|
|
816
|
+
const { reviewDigest, ...unsignedReview } = reviewed;
|
|
817
|
+
if (await brandJsonDigest(unsignedReview) !== reviewDigest) return false;
|
|
818
|
+
const expectedAction = await brandJsonDigest({
|
|
819
|
+
version: 1,
|
|
820
|
+
reviewedProposal: reviewed,
|
|
821
|
+
resolution: receipt2.resolution,
|
|
822
|
+
resolutionNote: receipt2.resolutionNote ?? null,
|
|
823
|
+
paletteId: receipt2.paletteId ?? null,
|
|
824
|
+
decisionBinding: binding
|
|
825
|
+
});
|
|
826
|
+
if (expectedAction !== receipt2.actionDigest) return false;
|
|
827
|
+
if (await brandJsonDigest({ bookId: receipt2.bookId, proposalId: receipt2.proposalId }) !== authority.resourceDigest) return false;
|
|
828
|
+
const { receiptDigest, ...immutable } = receipt2;
|
|
829
|
+
return await brandJsonDigest(immutable) === receiptDigest;
|
|
830
|
+
} catch {
|
|
831
|
+
return false;
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
// src/discussion-reference.ts
|
|
836
|
+
var BRAND_DISCUSSION_REFERENCE_KINDS = [
|
|
837
|
+
"brand:book",
|
|
838
|
+
"brand:asset",
|
|
839
|
+
"brand:palette",
|
|
840
|
+
"brand:proposal",
|
|
841
|
+
"brand:receipt"
|
|
842
|
+
];
|
|
843
|
+
var SEGMENT = /^[A-Za-z0-9][A-Za-z0-9._~-]{0,199}$/;
|
|
844
|
+
function brandDiscussionReferenceId(target) {
|
|
845
|
+
return target.resourceId ? `${target.bookId}/${target.resourceId}` : target.bookId;
|
|
846
|
+
}
|
|
847
|
+
function formatBrandDiscussionReference(target) {
|
|
848
|
+
return `${target.kind}/${brandDiscussionReferenceId(target)}`;
|
|
849
|
+
}
|
|
850
|
+
function parseBrandDiscussionReference(input) {
|
|
851
|
+
let raw;
|
|
852
|
+
try {
|
|
853
|
+
raw = input instanceof URL ? input.searchParams.get("odla-ref") : new URL(input, "https://brand.invalid").searchParams.get("odla-ref");
|
|
854
|
+
} catch {
|
|
855
|
+
return null;
|
|
856
|
+
}
|
|
857
|
+
if (!raw) return null;
|
|
858
|
+
const [kind, bookId, resourceId, extra] = raw.split("/");
|
|
859
|
+
if (extra !== void 0 || !BRAND_DISCUSSION_REFERENCE_KINDS.includes(
|
|
860
|
+
kind
|
|
861
|
+
) || !bookId || !SEGMENT.test(bookId)) return null;
|
|
862
|
+
const typedKind = kind;
|
|
863
|
+
if (typedKind === "brand:book") {
|
|
864
|
+
return resourceId === void 0 ? { kind: typedKind, bookId } : null;
|
|
865
|
+
}
|
|
866
|
+
return resourceId && SEGMENT.test(resourceId) ? { kind: typedKind, bookId, resourceId } : null;
|
|
867
|
+
}
|
|
868
|
+
function brandDiscussionReferenceHref(current, target, basePath = "/") {
|
|
869
|
+
const next = new URL(current.origin);
|
|
870
|
+
next.pathname = basePath.startsWith("/") ? basePath : `/${basePath}`;
|
|
871
|
+
next.searchParams.set("odla-ref", formatBrandDiscussionReference(target));
|
|
872
|
+
return next.toString();
|
|
873
|
+
}
|
|
874
|
+
|
|
503
875
|
// src/ops/books.ts
|
|
504
876
|
var SLUG_RE = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/;
|
|
505
877
|
function createBookOps(input) {
|
|
@@ -507,7 +879,11 @@ function createBookOps(input) {
|
|
|
507
879
|
if (!SLUG_RE.test(slug))
|
|
508
880
|
throw new BrandInputError("slug must be lowercase letters, digits, and inner hyphens");
|
|
509
881
|
const name = capString(input.name, "name", 120);
|
|
510
|
-
const
|
|
882
|
+
const ownerId = capString(input.ownerId, "ownerId", 160);
|
|
883
|
+
const createdAuthorityRef = capString(input.createdAuthorityRef, "createdAuthorityRef", 200);
|
|
884
|
+
const memberIds = Array.from(/* @__PURE__ */ new Set([ownerId, ...input.memberIds.map((id, index) => capString(id, `memberIds[${index}]`, 160))]));
|
|
885
|
+
if (memberIds.length > 100)
|
|
886
|
+
throw new BrandInputError("a brand book supports at most 100 members");
|
|
511
887
|
return [
|
|
512
888
|
{
|
|
513
889
|
t: "update",
|
|
@@ -515,11 +891,14 @@ function createBookOps(input) {
|
|
|
515
891
|
id: input.id,
|
|
516
892
|
attrs: {
|
|
517
893
|
id: input.id,
|
|
894
|
+
version: 1,
|
|
518
895
|
slug,
|
|
519
896
|
name,
|
|
520
897
|
status: "draft",
|
|
521
|
-
ownerId
|
|
898
|
+
ownerId,
|
|
899
|
+
createdAuthorityRef,
|
|
522
900
|
memberIds,
|
|
901
|
+
activationRevision: 0,
|
|
523
902
|
createdAt: input.now,
|
|
524
903
|
updatedAt: input.now,
|
|
525
904
|
...input.channelId ? { channelId: input.channelId } : {}
|
|
@@ -527,8 +906,8 @@ function createBookOps(input) {
|
|
|
527
906
|
}
|
|
528
907
|
];
|
|
529
908
|
}
|
|
530
|
-
var PATCHABLE = /* @__PURE__ */ new Set(["name", "status", "channelId", "summary"
|
|
531
|
-
var CLEARABLE = /* @__PURE__ */ new Set(["channelId", "summary"
|
|
909
|
+
var PATCHABLE = /* @__PURE__ */ new Set(["name", "status", "channelId", "summary"]);
|
|
910
|
+
var CLEARABLE = /* @__PURE__ */ new Set(["channelId", "summary"]);
|
|
532
911
|
function validateBookField(key, value) {
|
|
533
912
|
switch (key) {
|
|
534
913
|
case "name":
|
|
@@ -541,13 +920,8 @@ function validateBookField(key, value) {
|
|
|
541
920
|
return capString(value, "channelId", 128);
|
|
542
921
|
case "summary":
|
|
543
922
|
return capString(value, "summary", 2e3);
|
|
544
|
-
|
|
545
|
-
return capString(value, "activePaletteId", 128);
|
|
546
|
-
default: {
|
|
547
|
-
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
548
|
-
throw new BrandInputError("tokens must be an object");
|
|
923
|
+
default:
|
|
549
924
|
return value;
|
|
550
|
-
}
|
|
551
925
|
}
|
|
552
926
|
}
|
|
553
927
|
function updateBookOps(bookId, patch, now) {
|
|
@@ -594,6 +968,7 @@ function upsertSectionOps(input) {
|
|
|
594
968
|
if (!SECTION_STATUSES.includes(status))
|
|
595
969
|
throw new BrandInputError(`status must be one of: ${SECTION_STATUSES.join(", ")}`);
|
|
596
970
|
const content = assertSectionContent(input.kind, input.content);
|
|
971
|
+
if (status === "approved" && (!input.approvalReceiptId || !input.approvalActionDigest)) throw new BrandInputError("approved sections require approval receipt provenance");
|
|
597
972
|
const key = sectionKey(input.bookId, input.kind);
|
|
598
973
|
return [
|
|
599
974
|
{
|
|
@@ -608,47 +983,105 @@ function upsertSectionOps(input) {
|
|
|
608
983
|
content,
|
|
609
984
|
audience: input.audience,
|
|
610
985
|
updatedBy: input.updatedBy,
|
|
611
|
-
updatedAt: input.now
|
|
986
|
+
updatedAt: input.now,
|
|
987
|
+
...input.approvalReceiptId ? { approvalReceiptId: capString(input.approvalReceiptId, "approvalReceiptId", 200) } : {},
|
|
988
|
+
...input.approvalActionDigest ? { approvalActionDigest: capString(input.approvalActionDigest, "approvalActionDigest", 80) } : {}
|
|
612
989
|
}
|
|
990
|
+
},
|
|
991
|
+
{
|
|
992
|
+
t: "link",
|
|
993
|
+
ns: BRAND_NS.section,
|
|
994
|
+
id: { ns: BRAND_NS.section, attr: "key", value: key },
|
|
995
|
+
label: "book",
|
|
996
|
+
target: input.bookId
|
|
613
997
|
}
|
|
614
998
|
];
|
|
615
999
|
}
|
|
1000
|
+
async function proposeSectionOps(input) {
|
|
1001
|
+
if (input.taintLabels.length > 16)
|
|
1002
|
+
throw new BrandInputError("taintLabels must have at most 16 entries");
|
|
1003
|
+
const proposal = {
|
|
1004
|
+
id: input.id,
|
|
1005
|
+
bookId: input.bookId,
|
|
1006
|
+
kind: input.kind,
|
|
1007
|
+
status: "open",
|
|
1008
|
+
payload: { content: assertSectionContent(input.kind, input.content) },
|
|
1009
|
+
rationale: capString(input.rationale, "rationale", 2e3),
|
|
1010
|
+
provenance: {
|
|
1011
|
+
sourceAssetId: input.sourceAsset?.assetId ?? null,
|
|
1012
|
+
sourceAsset: input.sourceAsset ?? null,
|
|
1013
|
+
messageId: capString(input.messageId, "messageId", 200),
|
|
1014
|
+
turnId: capString(input.turnId, "turnId", 200),
|
|
1015
|
+
taintLabels: [...new Set(input.taintLabels.map((label, index) => capString(label, `taintLabels[${index}]`, 120)))].sort()
|
|
1016
|
+
},
|
|
1017
|
+
audience: input.audience,
|
|
1018
|
+
createdBy: input.createdBy,
|
|
1019
|
+
createdAuthorityRef: input.createdAuthorityRef,
|
|
1020
|
+
createdAt: input.now
|
|
1021
|
+
};
|
|
1022
|
+
return [
|
|
1023
|
+
{
|
|
1024
|
+
t: "update",
|
|
1025
|
+
ns: BRAND_NS.proposal,
|
|
1026
|
+
id: input.id,
|
|
1027
|
+
attrs: { ...proposal, reviewDigest: await brandJsonDigest(proposal) }
|
|
1028
|
+
},
|
|
1029
|
+
{ t: "link", ns: BRAND_NS.proposal, id: input.id, label: "book", target: input.bookId }
|
|
1030
|
+
];
|
|
1031
|
+
}
|
|
616
1032
|
|
|
617
1033
|
// src/ops/palettes.ts
|
|
618
|
-
function proposePaletteOps(input) {
|
|
1034
|
+
async function proposePaletteOps(input) {
|
|
619
1035
|
const name = capString(input.name, "name", 120);
|
|
620
1036
|
const rationale = capString(input.rationale, "rationale", 2e3);
|
|
621
1037
|
const swatches = assertSwatches(input.swatches);
|
|
622
1038
|
const seedHex = input.seedHex === void 0 ? void 0 : assertHex(input.seedHex, "seedHex");
|
|
1039
|
+
if (input.taintLabels.length > 16)
|
|
1040
|
+
throw new BrandInputError("taintLabels must have at most 16 entries");
|
|
623
1041
|
const payload = {
|
|
624
1042
|
name,
|
|
625
1043
|
swatches,
|
|
626
1044
|
...seedHex ? { seedHex } : {},
|
|
627
1045
|
...input.contrastReport !== void 0 ? { contrastReport: input.contrastReport } : {}
|
|
628
1046
|
};
|
|
1047
|
+
const proposal = {
|
|
1048
|
+
id: input.id,
|
|
1049
|
+
bookId: input.bookId,
|
|
1050
|
+
kind: "palette",
|
|
1051
|
+
status: "open",
|
|
1052
|
+
payload,
|
|
1053
|
+
rationale,
|
|
1054
|
+
provenance: {
|
|
1055
|
+
sourceAssetId: input.sourceAsset?.assetId ?? null,
|
|
1056
|
+
sourceAsset: input.sourceAsset ?? null,
|
|
1057
|
+
messageId: capString(input.messageId, "messageId", 200),
|
|
1058
|
+
turnId: capString(input.turnId, "turnId", 200),
|
|
1059
|
+
taintLabels: [...new Set(input.taintLabels.map((label, index) => capString(label, `taintLabels[${index}]`, 120)))].sort()
|
|
1060
|
+
},
|
|
1061
|
+
audience: input.audience,
|
|
1062
|
+
createdBy: input.createdBy,
|
|
1063
|
+
createdAuthorityRef: input.createdAuthorityRef,
|
|
1064
|
+
createdAt: input.now
|
|
1065
|
+
};
|
|
629
1066
|
return [
|
|
630
1067
|
{
|
|
631
1068
|
t: "update",
|
|
632
1069
|
ns: BRAND_NS.proposal,
|
|
633
1070
|
id: input.id,
|
|
634
|
-
attrs: {
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
kind: "palette",
|
|
638
|
-
status: "open",
|
|
639
|
-
payload,
|
|
640
|
-
rationale,
|
|
641
|
-
audience: input.audience,
|
|
642
|
-
createdBy: input.createdBy,
|
|
643
|
-
createdAt: input.now,
|
|
644
|
-
...input.sourceAssetId ? { sourceAssetId: input.sourceAssetId } : {},
|
|
645
|
-
...input.messageId ? { messageId: input.messageId } : {}
|
|
646
|
-
}
|
|
647
|
-
}
|
|
1071
|
+
attrs: { ...proposal, reviewDigest: await brandJsonDigest(proposal) }
|
|
1072
|
+
},
|
|
1073
|
+
{ t: "link", ns: BRAND_NS.proposal, id: input.id, label: "book", target: input.bookId }
|
|
648
1074
|
];
|
|
649
1075
|
}
|
|
650
1076
|
function acceptProposalOps(input) {
|
|
651
|
-
const {
|
|
1077
|
+
const {
|
|
1078
|
+
proposal,
|
|
1079
|
+
paletteId,
|
|
1080
|
+
resolvedBy,
|
|
1081
|
+
approvalReceiptId,
|
|
1082
|
+
approvalActionDigest,
|
|
1083
|
+
now
|
|
1084
|
+
} = input;
|
|
652
1085
|
if (proposal.kind !== "palette")
|
|
653
1086
|
throw new BrandInputError(`proposal ${proposal.id} is a ${proposal.kind} proposal, not a palette`);
|
|
654
1087
|
if (proposal.status !== "open")
|
|
@@ -657,7 +1090,7 @@ function acceptProposalOps(input) {
|
|
|
657
1090
|
const name = capString(payload.name, "payload.name", 120);
|
|
658
1091
|
const swatches = assertSwatches(payload.swatches);
|
|
659
1092
|
const seedHex = payload.seedHex === void 0 ? void 0 : assertHex(payload.seedHex, "payload.seedHex");
|
|
660
|
-
const source = proposal.
|
|
1093
|
+
const source = proposal.provenance.sourceAsset ? "extracted" : seedHex ? "derived" : "manual";
|
|
661
1094
|
return [
|
|
662
1095
|
{
|
|
663
1096
|
t: "update",
|
|
@@ -672,12 +1105,19 @@ function acceptProposalOps(input) {
|
|
|
672
1105
|
source,
|
|
673
1106
|
rationale: proposal.rationale,
|
|
674
1107
|
proposalId: proposal.id,
|
|
1108
|
+
approvalReceiptId: capString(approvalReceiptId, "approvalReceiptId", 200),
|
|
1109
|
+
approvalActionDigest: capString(
|
|
1110
|
+
approvalActionDigest,
|
|
1111
|
+
"approvalActionDigest",
|
|
1112
|
+
80
|
|
1113
|
+
),
|
|
675
1114
|
audience: proposal.audience,
|
|
676
1115
|
createdAt: now,
|
|
677
1116
|
updatedAt: now,
|
|
678
1117
|
...seedHex ? { seedHex } : {}
|
|
679
1118
|
}
|
|
680
1119
|
},
|
|
1120
|
+
{ t: "link", ns: BRAND_NS.palette, id: paletteId, label: "book", target: proposal.bookId },
|
|
681
1121
|
...upsertSectionOps({
|
|
682
1122
|
bookId: proposal.bookId,
|
|
683
1123
|
kind: "palette",
|
|
@@ -685,6 +1125,8 @@ function acceptProposalOps(input) {
|
|
|
685
1125
|
status: "approved",
|
|
686
1126
|
audience: proposal.audience,
|
|
687
1127
|
updatedBy: resolvedBy,
|
|
1128
|
+
approvalReceiptId,
|
|
1129
|
+
approvalActionDigest,
|
|
688
1130
|
now
|
|
689
1131
|
}),
|
|
690
1132
|
{
|
|
@@ -732,6 +1174,8 @@ function createAssetOps(input) {
|
|
|
732
1174
|
const contentType = assertAssetContentType(input.contentType);
|
|
733
1175
|
if (typeof input.size !== "number" || !Number.isFinite(input.size) || input.size <= 0)
|
|
734
1176
|
throw new BrandInputError("size must be a positive byte count");
|
|
1177
|
+
if (!/^sha256:[0-9a-f]{64}$/.test(input.contentDigest))
|
|
1178
|
+
throw new BrandInputError("contentDigest must be a SHA-256 digest");
|
|
735
1179
|
const title = input.title === void 0 ? void 0 : capString(input.title, "title", 160);
|
|
736
1180
|
return [
|
|
737
1181
|
{
|
|
@@ -743,27 +1187,68 @@ function createAssetOps(input) {
|
|
|
743
1187
|
bookId: input.bookId,
|
|
744
1188
|
kind: input.kind,
|
|
745
1189
|
path: capString(input.path, "path", 512),
|
|
746
|
-
|
|
1190
|
+
storageObjectId: capString(input.storageObjectId, "storageObjectId", 200),
|
|
1191
|
+
contentDigest: capString(input.contentDigest, "contentDigest", 80),
|
|
747
1192
|
contentType,
|
|
748
1193
|
size: input.size,
|
|
1194
|
+
status: "live",
|
|
1195
|
+
analysisRevision: 0,
|
|
749
1196
|
audience: input.audience,
|
|
750
1197
|
uploadedBy: input.uploadedBy,
|
|
1198
|
+
uploadedAuthorityRef: capString(
|
|
1199
|
+
input.uploadedAuthorityRef,
|
|
1200
|
+
"uploadedAuthorityRef",
|
|
1201
|
+
200
|
|
1202
|
+
),
|
|
751
1203
|
createdAt: input.now,
|
|
752
1204
|
...title ? { title } : {}
|
|
753
1205
|
}
|
|
754
|
-
}
|
|
1206
|
+
},
|
|
1207
|
+
{ t: "link", ns: BRAND_NS.asset, id: input.id, label: "book", target: input.bookId }
|
|
755
1208
|
];
|
|
756
1209
|
}
|
|
757
|
-
function
|
|
758
|
-
return [{
|
|
1210
|
+
function beginAssetDeleteOps(assetId, now, deletedBy, deletedAuthorityRef) {
|
|
1211
|
+
return [{
|
|
1212
|
+
t: "update",
|
|
1213
|
+
ns: BRAND_NS.asset,
|
|
1214
|
+
id: assetId,
|
|
1215
|
+
attrs: {
|
|
1216
|
+
status: "deleting",
|
|
1217
|
+
deletedAt: now,
|
|
1218
|
+
deletedBy: capString(deletedBy, "deletedBy", 160),
|
|
1219
|
+
deletedAuthorityRef: capString(deletedAuthorityRef, "deletedAuthorityRef", 200)
|
|
1220
|
+
}
|
|
1221
|
+
}];
|
|
1222
|
+
}
|
|
1223
|
+
function finishAssetDeleteOps(assetId) {
|
|
1224
|
+
return [{
|
|
1225
|
+
t: "update",
|
|
1226
|
+
ns: BRAND_NS.asset,
|
|
1227
|
+
id: assetId,
|
|
1228
|
+
attrs: { status: "deleted" }
|
|
1229
|
+
}];
|
|
759
1230
|
}
|
|
760
|
-
function recordAnalysisOps(assetId, analysis, now) {
|
|
1231
|
+
async function recordAnalysisOps(assetId, analysis, priorRevision, analyzedBy, analyzedAuthorityRef, now) {
|
|
1232
|
+
if (!Number.isSafeInteger(priorRevision) || priorRevision < 0)
|
|
1233
|
+
throw new BrandInputError("prior analysis revision must be a non-negative integer");
|
|
1234
|
+
const normalized = assertAnalysis(analysis);
|
|
761
1235
|
return [
|
|
762
1236
|
{
|
|
763
1237
|
t: "update",
|
|
764
1238
|
ns: BRAND_NS.asset,
|
|
765
1239
|
id: assetId,
|
|
766
|
-
attrs: {
|
|
1240
|
+
attrs: {
|
|
1241
|
+
analysis: normalized,
|
|
1242
|
+
analysisRevision: priorRevision + 1,
|
|
1243
|
+
analysisDigest: await brandJsonDigest(normalized),
|
|
1244
|
+
analyzedBy: capString(analyzedBy, "analyzedBy", 160),
|
|
1245
|
+
analyzedAuthorityRef: capString(
|
|
1246
|
+
analyzedAuthorityRef,
|
|
1247
|
+
"analyzedAuthorityRef",
|
|
1248
|
+
200
|
|
1249
|
+
),
|
|
1250
|
+
analyzedAt: now
|
|
1251
|
+
}
|
|
767
1252
|
}
|
|
768
1253
|
];
|
|
769
1254
|
}
|
|
@@ -1757,11 +2242,15 @@ function viewOutput(asset, bytes, contentType) {
|
|
|
1757
2242
|
}
|
|
1758
2243
|
function assetTools(ctx) {
|
|
1759
2244
|
const loadAsset = async (assetId) => {
|
|
2245
|
+
const book = await ctx.loadBook();
|
|
1760
2246
|
const res = await ctx.db.query({
|
|
1761
|
-
[BRAND_NS.asset]: {
|
|
2247
|
+
[BRAND_NS.asset]: {
|
|
2248
|
+
$: { where: { id: assetId, bookId: ctx.bookId } },
|
|
2249
|
+
book: {}
|
|
2250
|
+
}
|
|
1762
2251
|
});
|
|
1763
2252
|
const row = (res[BRAND_NS.asset] ?? [])[0];
|
|
1764
|
-
if (!row || row.deletedAt) throw new BrandNotFoundError(`asset ${assetId}`);
|
|
2253
|
+
if (!row || row.status !== "live" || row.deletedAt || !Array.isArray(row.book) || row.book.length !== 1 || row.book[0]?.id !== book.id) throw new BrandNotFoundError(`asset ${assetId}`);
|
|
1765
2254
|
return row;
|
|
1766
2255
|
};
|
|
1767
2256
|
const viewAsset = {
|
|
@@ -1780,8 +2269,8 @@ function assetTools(ctx) {
|
|
|
1780
2269
|
content: `This model cannot take images inside tool results. Asset ${asset.id} must be attached as a pre-turn image by the host instead \u2014 ask the human to re-send their message referencing the asset (the dispatcher attaches it up front), or describe it from what they tell you.`
|
|
1781
2270
|
};
|
|
1782
2271
|
}
|
|
1783
|
-
const
|
|
1784
|
-
const fetched = await ctx.
|
|
2272
|
+
const read = await ctx.authority("brand.read");
|
|
2273
|
+
const fetched = await ctx.readAssetContent(asset.id);
|
|
1785
2274
|
if (fetched.bytes.byteLength > MAX_VIEW_BYTES) {
|
|
1786
2275
|
return {
|
|
1787
2276
|
content: `Asset ${asset.id} is ${fetched.bytes.byteLength} bytes \u2014 over the ${MAX_VIEW_BYTES}-byte (4.5 MB) in-conversation viewing cap. Ask the human for a smaller export of this file.`
|
|
@@ -1799,7 +2288,8 @@ function assetTools(ctx) {
|
|
|
1799
2288
|
};
|
|
1800
2289
|
const recordAnalysis = {
|
|
1801
2290
|
name: "record_asset_analysis",
|
|
1802
|
-
description: "Record what you observed in an asset you viewed
|
|
2291
|
+
description: "Record what you observed in an asset you viewed. Uses guarded analysis revisions so concurrent agents cannot overwrite each other silently.",
|
|
2292
|
+
acceptsTaint: ["tool_untrusted:view_asset"],
|
|
1803
2293
|
inputSchema: {
|
|
1804
2294
|
type: "object",
|
|
1805
2295
|
required: ["assetId", "description", "dominantColors", "tags"],
|
|
@@ -1812,12 +2302,19 @@ function assetTools(ctx) {
|
|
|
1812
2302
|
},
|
|
1813
2303
|
handler: ctx.guard(async (input) => {
|
|
1814
2304
|
const asset = await loadAsset(String(input.assetId));
|
|
2305
|
+
await ctx.authority("brand.edit");
|
|
1815
2306
|
const analysis = assertAnalysis({
|
|
1816
2307
|
description: input.description,
|
|
1817
2308
|
dominantColors: input.dominantColors,
|
|
1818
2309
|
tags: input.tags
|
|
1819
2310
|
});
|
|
1820
|
-
|
|
2311
|
+
const updated = await ctx.recordAssetAnalysis({
|
|
2312
|
+
mutationId: ctx.newId(),
|
|
2313
|
+
assetId: asset.id,
|
|
2314
|
+
analysis,
|
|
2315
|
+
expectedAnalysisRevision: asset.analysisRevision
|
|
2316
|
+
});
|
|
2317
|
+
if (updated.id !== asset.id || updated.bookId !== ctx.bookId || updated.status !== "live" || updated.analysisRevision !== asset.analysisRevision + 1 || updated.analyzedBy !== ctx.self.selfId) throw new BrandNotFoundError("asset analysis bridge returned invalid state");
|
|
1821
2318
|
return {
|
|
1822
2319
|
content: `Recorded analysis for asset ${asset.id}: ${analysis.dominantColors.length} dominant color(s), ${analysis.tags.length} tag(s).`
|
|
1823
2320
|
};
|
|
@@ -1826,81 +2323,77 @@ function assetTools(ctx) {
|
|
|
1826
2323
|
return [viewAsset, recordAnalysis];
|
|
1827
2324
|
}
|
|
1828
2325
|
|
|
2326
|
+
// src/skill/source-asset.ts
|
|
2327
|
+
async function proposalSourceAssetId(ctx, book, value) {
|
|
2328
|
+
if (value === void 0) return void 0;
|
|
2329
|
+
const sourceAssetId = capString(value, "sourceAssetId", 200);
|
|
2330
|
+
const result = await ctx.db.query({
|
|
2331
|
+
[BRAND_NS.asset]: {
|
|
2332
|
+
$: {
|
|
2333
|
+
where: { id: sourceAssetId, bookId: book.id, status: "live" },
|
|
2334
|
+
limit: 2
|
|
2335
|
+
},
|
|
2336
|
+
book: { $: { limit: 2 } }
|
|
2337
|
+
}
|
|
2338
|
+
});
|
|
2339
|
+
const rows = result[BRAND_NS.asset] ?? [];
|
|
2340
|
+
const asset = rows.length === 1 ? rows[0] : void 0;
|
|
2341
|
+
if (!asset || asset.id !== sourceAssetId || asset.bookId !== book.id || asset.status !== "live" || asset.deletedAt !== void 0 || !Array.isArray(asset.book) || asset.book.length !== 1 || asset.book[0]?.id !== book.id) throw new BrandInputError(
|
|
2342
|
+
"sourceAssetId must be a live asset in this brand book"
|
|
2343
|
+
);
|
|
2344
|
+
return sourceAssetId;
|
|
2345
|
+
}
|
|
2346
|
+
|
|
1829
2347
|
// src/skill/book-tools.ts
|
|
2348
|
+
var PROPOSABLE = SECTION_KINDS.filter((kind) => kind !== "palette");
|
|
1830
2349
|
function bookTools(ctx) {
|
|
1831
|
-
|
|
2350
|
+
return [{
|
|
1832
2351
|
name: "update_section",
|
|
1833
|
-
description: "
|
|
2352
|
+
description: "Propose a typography, voice, logo, or imagery section revision for human approval. This never overwrites the live approved section.",
|
|
2353
|
+
acceptsTaint: ["tool_untrusted:view_asset"],
|
|
1834
2354
|
inputSchema: {
|
|
1835
2355
|
type: "object",
|
|
1836
|
-
required: ["kind", "content"],
|
|
2356
|
+
required: ["kind", "content", "rationale"],
|
|
1837
2357
|
properties: {
|
|
1838
|
-
kind: { type: "string", enum:
|
|
1839
|
-
content: { type: "object", description: "The section payload
|
|
1840
|
-
|
|
2358
|
+
kind: { type: "string", enum: PROPOSABLE },
|
|
2359
|
+
content: { type: "object", description: "The complete proposed section payload." },
|
|
2360
|
+
rationale: { type: "string", description: "Why this revision should replace the live section." },
|
|
2361
|
+
sourceAssetId: {
|
|
2362
|
+
type: "string",
|
|
2363
|
+
description: "Optional exact live Brand asset that grounds this revision."
|
|
2364
|
+
}
|
|
1841
2365
|
}
|
|
1842
2366
|
},
|
|
1843
2367
|
handler: ctx.guard(async (input) => {
|
|
2368
|
+
const allowed = /* @__PURE__ */ new Set(["kind", "content", "rationale", "sourceAssetId"]);
|
|
2369
|
+
if (Object.keys(input).some((key) => !allowed.has(key)))
|
|
2370
|
+
throw new BrandInputError("section proposal has unknown fields");
|
|
1844
2371
|
const book = await ctx.loadBook();
|
|
1845
|
-
|
|
1846
|
-
const
|
|
1847
|
-
|
|
2372
|
+
await ctx.authority("brand.edit");
|
|
2373
|
+
const kind = input.kind;
|
|
2374
|
+
if (kind === "palette" || !PROPOSABLE.includes(kind))
|
|
2375
|
+
throw new BrandInputError(`kind must be one of: ${PROPOSABLE.join(", ")}`);
|
|
2376
|
+
const rationale = capString(input.rationale, "rationale", 2e3);
|
|
2377
|
+
const sourceAssetId = await proposalSourceAssetId(
|
|
2378
|
+
ctx,
|
|
2379
|
+
book,
|
|
2380
|
+
input.sourceAssetId
|
|
2381
|
+
);
|
|
2382
|
+
const proposal = await ctx.createProposal({
|
|
2383
|
+
mutationId: ctx.newId(),
|
|
1848
2384
|
kind,
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
});
|
|
1855
|
-
await ctx.db.transact(ops, { mutationId: ctx.newId() });
|
|
1856
|
-
const status = input.status === void 0 ? "draft" : String(input.status);
|
|
1857
|
-
return { content: `Saved ${kind} section as ${status} (key ${sectionKey(ctx.bookId, kind)}).` };
|
|
1858
|
-
})
|
|
1859
|
-
};
|
|
1860
|
-
const compileTokens = {
|
|
1861
|
-
name: "compile_tokens",
|
|
1862
|
-
description: "Compile the book's palette (paletteId argument, or the active palette) plus the typography section into light/dark @odla-ai/ui tokens, cached on the book. Returns the compiler's warnings.",
|
|
1863
|
-
inputSchema: {
|
|
1864
|
-
type: "object",
|
|
1865
|
-
properties: { paletteId: { type: "string", description: "Defaults to the book's active palette." } }
|
|
1866
|
-
},
|
|
1867
|
-
handler: ctx.guard(async (input) => {
|
|
1868
|
-
const book = await ctx.loadBook();
|
|
1869
|
-
const paletteId = input.paletteId === void 0 ? book.activePaletteId : capString(input.paletteId, "paletteId", 128);
|
|
1870
|
-
if (!paletteId) {
|
|
1871
|
-
throw new BrandInputError("no palette to compile: pass paletteId, or get a palette proposal accepted first");
|
|
1872
|
-
}
|
|
1873
|
-
const pres = await ctx.db.query({
|
|
1874
|
-
[BRAND_NS.palette]: { $: { where: { id: paletteId, bookId: ctx.bookId } } }
|
|
1875
|
-
});
|
|
1876
|
-
const palette = (pres[BRAND_NS.palette] ?? [])[0];
|
|
1877
|
-
if (!palette) throw new BrandNotFoundError(`palette ${paletteId}`);
|
|
1878
|
-
const sres = await ctx.db.query({
|
|
1879
|
-
[BRAND_NS.section]: { $: { where: { key: sectionKey(ctx.bookId, "typography") } } }
|
|
1880
|
-
});
|
|
1881
|
-
const typography = (sres[BRAND_NS.section] ?? [])[0];
|
|
1882
|
-
const compiled = compileBrandTokens({
|
|
1883
|
-
swatches: palette.swatches,
|
|
1884
|
-
...typography ? { typography: typography.content } : {}
|
|
2385
|
+
payload: {
|
|
2386
|
+
content: assertSectionContent(kind, input.content)
|
|
2387
|
+
},
|
|
2388
|
+
rationale,
|
|
2389
|
+
...sourceAssetId ? { sourceAssetId } : {}
|
|
1885
2390
|
});
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
dark: compiled.dark,
|
|
1890
|
-
warnings: compiled.warnings,
|
|
1891
|
-
compiledAt: now
|
|
2391
|
+
if (proposal.bookId !== book.id || proposal.createdBy !== ctx.self.selfId || proposal.kind !== kind || proposal.status !== "open") throw new BrandInputError("brand proposal bridge returned an invalid proposal");
|
|
2392
|
+
return {
|
|
2393
|
+
content: `Parked ${kind} section proposal ${proposal.id}. The live section is unchanged; a human must approve this exact revision.`
|
|
1892
2394
|
};
|
|
1893
|
-
await ctx.db.transact(updateBookOps(ctx.bookId, { tokens: snapshot }, now), { mutationId: ctx.newId() });
|
|
1894
|
-
const count = compiled.warnings.length;
|
|
1895
|
-
const lines = [
|
|
1896
|
-
`Compiled ${Object.keys(compiled.light).length} light + ${Object.keys(compiled.dark).length} dark tokens from palette ${paletteId}; ${count} warning(s).`,
|
|
1897
|
-
...compiled.warnings.slice(0, 3).map((w) => `- ${w.token}: ${w.message}${w.adjustedFrom ? ` (adjusted from ${w.adjustedFrom})` : ""}`)
|
|
1898
|
-
];
|
|
1899
|
-
if (count > 3) lines.push(`\u2026 and ${count - 3} more.`);
|
|
1900
|
-
return { content: lines.join("\n") };
|
|
1901
2395
|
})
|
|
1902
|
-
};
|
|
1903
|
-
return [updateSection, compileTokens];
|
|
2396
|
+
}];
|
|
1904
2397
|
}
|
|
1905
2398
|
|
|
1906
2399
|
// src/skill/palette-tools.ts
|
|
@@ -2028,6 +2521,7 @@ function paletteTools(ctx) {
|
|
|
2028
2521
|
const proposePalette = {
|
|
2029
2522
|
name: "propose_palette",
|
|
2030
2523
|
description: "Park a palette proposal for human review (does NOT change the brand). Pass explicit swatches, or a seedHex (and optional harmony) to derive a full palette. A WCAG contrast report is attached automatically.",
|
|
2524
|
+
acceptsTaint: ["tool_untrusted:view_asset"],
|
|
2031
2525
|
inputSchema: {
|
|
2032
2526
|
type: "object",
|
|
2033
2527
|
required: ["name", "rationale"],
|
|
@@ -2036,6 +2530,10 @@ function paletteTools(ctx) {
|
|
|
2036
2530
|
rationale: { type: "string", description: "Why these colors \u2014 cite assets, harmony, contrast." },
|
|
2037
2531
|
seedHex: { type: "string" },
|
|
2038
2532
|
harmony: { type: "string", enum: Object.keys(HARMONY_COMPANIONS) },
|
|
2533
|
+
sourceAssetId: {
|
|
2534
|
+
type: "string",
|
|
2535
|
+
description: "Optional exact asset id that grounded this palette."
|
|
2536
|
+
},
|
|
2039
2537
|
swatches: {
|
|
2040
2538
|
type: "array",
|
|
2041
2539
|
items: {
|
|
@@ -2051,8 +2549,16 @@ function paletteTools(ctx) {
|
|
|
2051
2549
|
}
|
|
2052
2550
|
}
|
|
2053
2551
|
},
|
|
2054
|
-
handler: ctx.guard(async (input) => {
|
|
2552
|
+
handler: ctx.guard(async (input, toolCtx) => {
|
|
2055
2553
|
const book = await ctx.loadBook();
|
|
2554
|
+
await ctx.authority("brand.edit");
|
|
2555
|
+
const name = capString(input.name, "name", 120);
|
|
2556
|
+
const rationale = capString(input.rationale, "rationale", 2e3);
|
|
2557
|
+
const sourceAssetId = await proposalSourceAssetId(
|
|
2558
|
+
ctx,
|
|
2559
|
+
book,
|
|
2560
|
+
input.sourceAssetId
|
|
2561
|
+
);
|
|
2056
2562
|
const seedHex = input.seedHex === void 0 ? void 0 : assertHex(input.seedHex, "seedHex");
|
|
2057
2563
|
let swatches;
|
|
2058
2564
|
if (input.swatches !== void 0) {
|
|
@@ -2063,84 +2569,53 @@ function paletteTools(ctx) {
|
|
|
2063
2569
|
if (input.harmony !== void 0) swatches = applyHarmony(swatches, seedHex, input.harmony);
|
|
2064
2570
|
}
|
|
2065
2571
|
const report = contrastReport(swatches);
|
|
2066
|
-
const
|
|
2067
|
-
|
|
2068
|
-
|
|
2069
|
-
|
|
2070
|
-
|
|
2071
|
-
|
|
2072
|
-
|
|
2073
|
-
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
now: ctx.now()
|
|
2572
|
+
const proposal = await ctx.createProposal({
|
|
2573
|
+
mutationId: ctx.newId(),
|
|
2574
|
+
kind: "palette",
|
|
2575
|
+
payload: {
|
|
2576
|
+
name,
|
|
2577
|
+
swatches,
|
|
2578
|
+
...seedHex ? { seedHex } : {},
|
|
2579
|
+
contrastReport: report
|
|
2580
|
+
},
|
|
2581
|
+
rationale,
|
|
2582
|
+
...sourceAssetId ? { sourceAssetId } : {}
|
|
2078
2583
|
});
|
|
2079
|
-
|
|
2584
|
+
if (proposal.bookId !== book.id || proposal.createdBy !== ctx.self.selfId || proposal.kind !== "palette" || proposal.status !== "open") throw new BrandInputError("brand proposal bridge returned an invalid proposal");
|
|
2080
2585
|
const reportText = Object.entries(report).map(([k, v]) => `${k} ${r2(v)}:1`).join(", ");
|
|
2081
2586
|
return {
|
|
2082
|
-
content: `Parked palette proposal ${id} ("${
|
|
2587
|
+
content: `Parked palette proposal ${proposal.id} ("${name}", ${swatches.length} swatches${seedHex ? `, seeded from ${seedHex}` : ""}). Contrast: ${reportText}. Awaiting a human decision in the brand approval surface.`
|
|
2083
2588
|
};
|
|
2084
2589
|
})
|
|
2085
2590
|
};
|
|
2086
|
-
|
|
2087
|
-
name: "resolve_proposal",
|
|
2088
|
-
description: "Apply the human's explicit verdict on an open proposal. ONLY call this after the human has clearly accepted or rejected in the conversation \u2014 never on your own initiative.",
|
|
2089
|
-
inputSchema: {
|
|
2090
|
-
type: "object",
|
|
2091
|
-
required: ["proposalId", "resolution"],
|
|
2092
|
-
properties: {
|
|
2093
|
-
proposalId: { type: "string" },
|
|
2094
|
-
resolution: { type: "string", enum: ["accepted", "rejected"] },
|
|
2095
|
-
note: { type: "string" }
|
|
2096
|
-
}
|
|
2097
|
-
},
|
|
2098
|
-
handler: ctx.guard(async (input) => {
|
|
2099
|
-
const proposalId = String(input.proposalId);
|
|
2100
|
-
const res = await ctx.db.query({
|
|
2101
|
-
[BRAND_NS.proposal]: { $: { where: { id: proposalId, bookId: ctx.bookId } } }
|
|
2102
|
-
});
|
|
2103
|
-
const proposal = (res[BRAND_NS.proposal] ?? [])[0];
|
|
2104
|
-
if (!proposal) throw new BrandNotFoundError(`proposal ${proposalId}`);
|
|
2105
|
-
const note = input.note === void 0 ? void 0 : capString(input.note, "note", 1e3);
|
|
2106
|
-
const now = ctx.now();
|
|
2107
|
-
if (input.resolution === "accepted") {
|
|
2108
|
-
const paletteId = ctx.newId();
|
|
2109
|
-
const ops = acceptProposalOps({ proposal, paletteId, resolvedBy: ctx.self.selfId, now, resolutionNote: note });
|
|
2110
|
-
await ctx.db.transact(ops, { mutationId: paletteId });
|
|
2111
|
-
return { content: `Proposal ${proposalId} accepted \u2014 palette ${paletteId} written and set as the book's active palette.` };
|
|
2112
|
-
}
|
|
2113
|
-
if (input.resolution === "rejected") {
|
|
2114
|
-
const ops = rejectProposalOps({ proposal, resolvedBy: ctx.self.selfId, now, resolutionNote: note });
|
|
2115
|
-
await ctx.db.transact(ops, { mutationId: ctx.newId() });
|
|
2116
|
-
return { content: `Proposal ${proposalId} rejected.` };
|
|
2117
|
-
}
|
|
2118
|
-
throw new BrandInputError('resolution must be "accepted" or "rejected"');
|
|
2119
|
-
})
|
|
2120
|
-
};
|
|
2121
|
-
return [analyzeColor, evaluateContrast, proposePalette, resolveProposal];
|
|
2591
|
+
return [analyzeColor, evaluateContrast, proposePalette];
|
|
2122
2592
|
}
|
|
2123
2593
|
|
|
2124
2594
|
// src/skill/read-tools.ts
|
|
2125
2595
|
var iso = (ms) => new Date(ms).toISOString();
|
|
2126
|
-
|
|
2596
|
+
var shortId = (id) => id.length <= 12 ? id : `${id.slice(0, 6)}\u2026${id.slice(-4)}`;
|
|
2597
|
+
var principalLabel = (id, directory) => {
|
|
2598
|
+
const value = directory.get(id);
|
|
2599
|
+
return value ? `${value.displayName} [${value.kind} \xB7 ${shortId(id)}]` : `Unknown principal [${shortId(id)}]`;
|
|
2600
|
+
};
|
|
2601
|
+
function bookLines(book, directory) {
|
|
2127
2602
|
const lines = [
|
|
2128
2603
|
`brand book "${book.name}" (${book.slug}) \u2014 ${book.status}`,
|
|
2129
|
-
`members: ${book.memberIds.join(", ")}`,
|
|
2604
|
+
`members: ${book.memberIds.map((id) => principalLabel(id, directory)).join(", ")}`,
|
|
2130
2605
|
`active palette: ${book.activePaletteId ?? "(none accepted yet)"}`,
|
|
2131
2606
|
book.tokens ? `tokens: compiled ${iso(book.tokens.compiledAt)} with ${book.tokens.warnings.length} warning(s)` : "tokens: (not compiled)"
|
|
2132
2607
|
];
|
|
2133
2608
|
if (book.summary) lines.push(`summary: ${book.summary}`);
|
|
2134
2609
|
return lines;
|
|
2135
2610
|
}
|
|
2136
|
-
function childLines(sections, palettes, proposals) {
|
|
2611
|
+
function childLines(sections, palettes, proposals, directory) {
|
|
2137
2612
|
return [
|
|
2138
2613
|
"sections:",
|
|
2139
|
-
...sections.length ? sections.map((s) => `- ${s.kind}: ${s.status}, updated ${iso(s.updatedAt)} by
|
|
2614
|
+
...sections.length ? sections.map((s) => `- ${s.kind}: ${s.status}, updated ${iso(s.updatedAt)} by ` + principalLabel(s.updatedBy, directory)) : ["- (none)"],
|
|
2140
2615
|
"palettes:",
|
|
2141
2616
|
...palettes.length ? palettes.map((p) => `- ${p.id} "${p.name}" (${p.status}, ${p.source}, ${p.swatches.length} swatches)`) : ["- (none)"],
|
|
2142
2617
|
"open proposals:",
|
|
2143
|
-
...proposals.length ? proposals.map((p) => `- ${p.id} (${p.kind}) by ${p.createdBy}: ${p.rationale}`) : ["- (none)"]
|
|
2618
|
+
...proposals.length ? proposals.map((p) => `- ${p.id} (${p.kind}) by ${principalLabel(p.createdBy, directory)}: ${p.rationale}`) : ["- (none)"]
|
|
2144
2619
|
];
|
|
2145
2620
|
}
|
|
2146
2621
|
function readTools(ctx) {
|
|
@@ -2149,18 +2624,48 @@ function readTools(ctx) {
|
|
|
2149
2624
|
description: "Read the current brand book: status, members, active palette, compiled tokens, sections, palettes, and open proposals.",
|
|
2150
2625
|
inputSchema: { type: "object", properties: {} },
|
|
2151
2626
|
handler: ctx.guard(async () => {
|
|
2627
|
+
const authorizedBook = await ctx.loadBook();
|
|
2152
2628
|
const res = await ctx.db.query({
|
|
2153
|
-
[BRAND_NS.
|
|
2154
|
-
|
|
2155
|
-
|
|
2156
|
-
|
|
2629
|
+
[BRAND_NS.section]: {
|
|
2630
|
+
$: { where: { bookId: ctx.bookId }, order: { updatedAt: "asc" } },
|
|
2631
|
+
book: {}
|
|
2632
|
+
},
|
|
2633
|
+
[BRAND_NS.palette]: {
|
|
2634
|
+
$: { where: { bookId: ctx.bookId }, order: { createdAt: "asc" } },
|
|
2635
|
+
book: {}
|
|
2636
|
+
},
|
|
2637
|
+
[BRAND_NS.proposal]: {
|
|
2638
|
+
$: {
|
|
2639
|
+
where: { bookId: ctx.bookId, status: "open" },
|
|
2640
|
+
order: { createdAt: "asc" }
|
|
2641
|
+
},
|
|
2642
|
+
book: {}
|
|
2643
|
+
}
|
|
2157
2644
|
});
|
|
2158
|
-
const
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
const
|
|
2163
|
-
|
|
2645
|
+
const linked = (rows) => rows.filter((row) => row.bookId === authorizedBook.id && Array.isArray(row.book) && row.book.length === 1 && row.book[0]?.id === authorizedBook.id);
|
|
2646
|
+
const sections = linked(
|
|
2647
|
+
res[BRAND_NS.section] ?? []
|
|
2648
|
+
);
|
|
2649
|
+
const palettes = linked(
|
|
2650
|
+
res[BRAND_NS.palette] ?? []
|
|
2651
|
+
);
|
|
2652
|
+
const proposals = linked(
|
|
2653
|
+
res[BRAND_NS.proposal] ?? []
|
|
2654
|
+
);
|
|
2655
|
+
const ids = [
|
|
2656
|
+
...authorizedBook.memberIds,
|
|
2657
|
+
...sections.map((section) => section.updatedBy),
|
|
2658
|
+
...proposals.map((proposal) => proposal.createdBy)
|
|
2659
|
+
];
|
|
2660
|
+
const directory = new Map(
|
|
2661
|
+
(await ctx.resolvePrincipals(ids)).map((principal) => [principal.id, principal])
|
|
2662
|
+
);
|
|
2663
|
+
return {
|
|
2664
|
+
content: [
|
|
2665
|
+
...bookLines(authorizedBook, directory),
|
|
2666
|
+
...childLines(sections, palettes, proposals, directory)
|
|
2667
|
+
].join("\n")
|
|
2668
|
+
};
|
|
2164
2669
|
})
|
|
2165
2670
|
};
|
|
2166
2671
|
const listAssets = {
|
|
@@ -2173,15 +2678,21 @@ function readTools(ctx) {
|
|
|
2173
2678
|
}
|
|
2174
2679
|
},
|
|
2175
2680
|
handler: ctx.guard(async (input) => {
|
|
2176
|
-
|
|
2681
|
+
await ctx.loadBook();
|
|
2682
|
+
const where = { bookId: ctx.bookId, status: "live" };
|
|
2177
2683
|
if (input.kind !== void 0) {
|
|
2178
2684
|
if (typeof input.kind !== "string" || !ASSET_KINDS.includes(input.kind)) {
|
|
2179
2685
|
throw new BrandInputError(`kind must be one of: ${ASSET_KINDS.join(", ")}`);
|
|
2180
2686
|
}
|
|
2181
2687
|
where.kind = input.kind;
|
|
2182
2688
|
}
|
|
2183
|
-
const res = await ctx.db.query({
|
|
2184
|
-
|
|
2689
|
+
const res = await ctx.db.query({
|
|
2690
|
+
[BRAND_NS.asset]: {
|
|
2691
|
+
$: { where, order: { createdAt: "asc" } },
|
|
2692
|
+
book: {}
|
|
2693
|
+
}
|
|
2694
|
+
});
|
|
2695
|
+
const rows = (res[BRAND_NS.asset] ?? []).filter((asset) => asset.bookId === ctx.bookId && Array.isArray(asset.book) && asset.book.length === 1 && asset.book[0]?.id === ctx.bookId);
|
|
2185
2696
|
if (rows.length === 0) {
|
|
2186
2697
|
return { content: input.kind ? `(no ${String(input.kind)} assets uploaded yet)` : "(no assets uploaded yet)" };
|
|
2187
2698
|
}
|
|
@@ -2195,29 +2706,64 @@ function readTools(ctx) {
|
|
|
2195
2706
|
}
|
|
2196
2707
|
|
|
2197
2708
|
// src/skill/skill.ts
|
|
2198
|
-
var BRAND_INSTRUCTIONS = "You help build and maintain ONE brand book. Workflow, in order:\n1. Understand the brand first: read_brand_book and list_assets before proposing anything.\n2. Look at the real material: view_asset on logos and inspiration, then record what you saw with record_asset_analysis (description, dominant colors as hex, tags).\n3. Explore with the math tools: analyze_color and evaluate_contrast. Never invent a hex without a rationale \u2014 ground every color in an asset's dominant color, a harmony companion, or a contrast fix, and say which.\n4. propose_palette parks a proposal for review. It does NOT change the brand.\n5.
|
|
2709
|
+
var BRAND_INSTRUCTIONS = "You help build and maintain ONE brand book. Workflow, in order:\n1. Understand the brand first: read_brand_book and list_assets before proposing anything.\n2. Look at the real material: view_asset on logos and inspiration, then record what you saw with record_asset_analysis (description, dominant colors as hex, tags).\n3. Explore with the math tools: analyze_color and evaluate_contrast. Never invent a hex without a rationale \u2014 ground every color in an asset's dominant color, a harmony companion, or a contrast fix, and say which.\n4. propose_palette parks a proposal for review. It does NOT change the brand.\n5. You cannot approve or resolve a proposal. Direct the human to the brand approval surface, which records a guarded receipt for their decision.\n6. update_section parks a proposal; it never overwrites an approved facet. Bind sourceAssetId when a real Brand asset grounds typography, voice, logo, or imagery. Human acceptance applies the exact reviewed change and automatically recompiles dependent tokens.\n7. Re-read the book after a decision and explain any compiler warnings conversationally.";
|
|
2199
2710
|
function brandSkill(opts) {
|
|
2200
|
-
|
|
2711
|
+
if (opts.agentDbBinding.principalId !== opts.self.selfId || !opts.agentDbBinding.credentialRef) throw new BrandForbiddenError("brand db is not bound to the acting agent");
|
|
2712
|
+
const deps = resolveDeps({ db: opts.db, now: opts.now, newId: opts.newId });
|
|
2713
|
+
const authority = (capability) => Promise.resolve(opts.authorizeCapability({
|
|
2714
|
+
agentId: opts.self.selfId,
|
|
2715
|
+
bookId: opts.bookId,
|
|
2716
|
+
capability
|
|
2717
|
+
}) ?? null).then((result) => {
|
|
2718
|
+
if (!result || result.capability !== capability || !result.authorityRef)
|
|
2719
|
+
throw new BrandForbiddenError(`agent lacks ${capability} for brand book ${opts.bookId}`);
|
|
2720
|
+
return result;
|
|
2721
|
+
});
|
|
2201
2722
|
const ctx = {
|
|
2202
2723
|
db: deps.db,
|
|
2203
2724
|
bookId: opts.bookId,
|
|
2204
2725
|
self: opts.self,
|
|
2205
|
-
fileBaseUrl: opts.fileBaseUrl,
|
|
2206
|
-
fetchBytes: deps.fetchBytes,
|
|
2207
2726
|
visionInToolResults: opts.visionInToolResults !== false,
|
|
2208
2727
|
now: deps.now,
|
|
2209
2728
|
newId: deps.newId,
|
|
2729
|
+
agentJobId: opts.agentJobId,
|
|
2730
|
+
createProposal: (input) => opts.agentBridge.createProposal({
|
|
2731
|
+
jobId: opts.agentJobId,
|
|
2732
|
+
bookId: opts.bookId,
|
|
2733
|
+
...input
|
|
2734
|
+
}),
|
|
2735
|
+
recordAssetAnalysis: (input) => opts.agentBridge.recordAssetAnalysis({
|
|
2736
|
+
jobId: opts.agentJobId,
|
|
2737
|
+
bookId: opts.bookId,
|
|
2738
|
+
...input
|
|
2739
|
+
}),
|
|
2740
|
+
readAssetContent: (assetId) => opts.agentBridge.readAssetContent({
|
|
2741
|
+
jobId: opts.agentJobId,
|
|
2742
|
+
bookId: opts.bookId,
|
|
2743
|
+
assetId
|
|
2744
|
+
}),
|
|
2745
|
+
resolvePrincipals: (principalIds) => {
|
|
2746
|
+
const ids = [...new Set(principalIds)].slice(0, 100);
|
|
2747
|
+
return opts.resolvePrincipals({
|
|
2748
|
+
requesterAgentId: opts.self.selfId,
|
|
2749
|
+
bookId: opts.bookId,
|
|
2750
|
+
principalIds: ids
|
|
2751
|
+
});
|
|
2752
|
+
},
|
|
2753
|
+
authority,
|
|
2210
2754
|
loadBook: async () => {
|
|
2755
|
+
await authority("brand.read");
|
|
2211
2756
|
const res = await deps.db.query({ [BRAND_NS.book]: { $: { where: { id: opts.bookId } } } });
|
|
2212
2757
|
const row = (res[BRAND_NS.book] ?? [])[0];
|
|
2213
|
-
if (!row
|
|
2758
|
+
if (!row || !row.memberIds.includes(opts.self.selfId))
|
|
2759
|
+
throw new BrandNotFoundError(`brand book ${opts.bookId}`);
|
|
2214
2760
|
return row;
|
|
2215
2761
|
},
|
|
2216
2762
|
guard: (handler) => async (input, toolCtx) => {
|
|
2217
2763
|
try {
|
|
2218
2764
|
return await handler(input, toolCtx);
|
|
2219
2765
|
} catch (error) {
|
|
2220
|
-
if (error instanceof BrandInputError || error instanceof BrandNotFoundError) {
|
|
2766
|
+
if (error instanceof BrandInputError || error instanceof BrandNotFoundError || error instanceof BrandForbiddenError) {
|
|
2221
2767
|
return { content: error.message, isError: true };
|
|
2222
2768
|
}
|
|
2223
2769
|
throw error;
|
|
@@ -2232,7 +2778,7 @@ function brandSkill(opts) {
|
|
|
2232
2778
|
}
|
|
2233
2779
|
|
|
2234
2780
|
// src/skill/persona.ts
|
|
2235
|
-
var DEFAULT_BRAND_SYSTEM = "You are a meticulous brand director for one brand book. You study the real material before forming opinions, you justify every color with math (harmony, \u0394EOK, WCAG contrast) or provenance (an asset's dominant colors), and you present options rather than dictating. Proposals are yours to make; decisions are the human's \u2014
|
|
2781
|
+
var DEFAULT_BRAND_SYSTEM = "You are a meticulous brand director for one brand book. You study the real material before forming opinions, you justify every color with math (harmony, \u0394EOK, WCAG contrast) or provenance (an asset's dominant colors), and you present options rather than dictating. Proposals and drafts are yours to make; decisions are the human's \u2014 you have no proposal-resolution or section-approval tool. When tokens compile with warnings, explain each adjustment in plain language.";
|
|
2236
2782
|
function createBrandPersona(opts) {
|
|
2237
2783
|
return {
|
|
2238
2784
|
name: "brand-director",
|
|
@@ -2248,7 +2794,35 @@ function supportsBrandVision(spec) {
|
|
|
2248
2794
|
return !!(caps?.toolResultBlocks && caps.imageIn && caps.documentIn);
|
|
2249
2795
|
}
|
|
2250
2796
|
|
|
2797
|
+
// src/routes/asset-query.ts
|
|
2798
|
+
async function linkedAsset(ctx, book, assetId, allowDeleting = false) {
|
|
2799
|
+
const result = await ctx.db.query({
|
|
2800
|
+
[BRAND_NS.asset]: {
|
|
2801
|
+
$: { where: { id: assetId, bookId: book.id } },
|
|
2802
|
+
book: {}
|
|
2803
|
+
}
|
|
2804
|
+
});
|
|
2805
|
+
const row = (result[BRAND_NS.asset] ?? [])[0];
|
|
2806
|
+
const links = row?.book;
|
|
2807
|
+
if (!row || row.bookId !== book.id || !Array.isArray(links) || links.length !== 1 || links[0]?.id !== book.id || row.status !== "live" && !(allowDeleting && row.status === "deleting")) throw new BrandNotFoundError(`asset ${assetId}`);
|
|
2808
|
+
return row;
|
|
2809
|
+
}
|
|
2810
|
+
|
|
2251
2811
|
// src/routes/http.ts
|
|
2812
|
+
async function requireRouteAuthority(ctx, req, capability, effect, bookId) {
|
|
2813
|
+
if (ctx.actor.kind !== "human")
|
|
2814
|
+
throw new BrandForbiddenError("this brand effect requires a directly authenticated human");
|
|
2815
|
+
const result = await ctx.authorizeCapability({
|
|
2816
|
+
req,
|
|
2817
|
+
actor: ctx.actor,
|
|
2818
|
+
appId: ctx.appId,
|
|
2819
|
+
...bookId ? { bookId } : {},
|
|
2820
|
+
capability,
|
|
2821
|
+
effect
|
|
2822
|
+
});
|
|
2823
|
+
if (!result || result.actorPrincipalId !== ctx.actor.id || result.capability !== capability || result.effect !== effect || typeof result.authorityRef !== "string" || result.authorityRef.length < 1 || result.authorityRef.length > 200) throw new BrandForbiddenError(`human lacks ${capability} ${effect} authority`);
|
|
2824
|
+
return result;
|
|
2825
|
+
}
|
|
2252
2826
|
var json = (body, status = 200, headers = {}) => new Response(JSON.stringify(body), {
|
|
2253
2827
|
status,
|
|
2254
2828
|
headers: { "content-type": "application/json", ...headers }
|
|
@@ -2256,7 +2830,12 @@ var json = (body, status = 200, headers = {}) => new Response(JSON.stringify(bod
|
|
|
2256
2830
|
var errorResponse = (error) => {
|
|
2257
2831
|
if (error instanceof BrandInputError)
|
|
2258
2832
|
return json({ error: error.message, ...error.fields ? { fields: error.fields } : {} }, 400);
|
|
2833
|
+
if (error instanceof BrandForbiddenError) return json({ error: error.message }, 403);
|
|
2259
2834
|
if (error instanceof BrandNotFoundError) return json({ error: error.message }, 404);
|
|
2835
|
+
if (error instanceof BrandGoneError) return json({ error: error.message }, 410);
|
|
2836
|
+
if (error instanceof BrandReviewStateChangedError)
|
|
2837
|
+
return json({ error: error.message, code: error.code }, 409);
|
|
2838
|
+
if (error instanceof BrandConflictError) return json({ error: error.message }, 409);
|
|
2260
2839
|
return json({ error: "internal error" }, 500);
|
|
2261
2840
|
};
|
|
2262
2841
|
var methodNotAllowed = () => json({ error: "method not allowed" }, 405);
|
|
@@ -2305,86 +2884,217 @@ async function loadMemberBook(db, bookId, actorId) {
|
|
|
2305
2884
|
return book;
|
|
2306
2885
|
}
|
|
2307
2886
|
|
|
2308
|
-
// src/routes/
|
|
2887
|
+
// src/routes/asset-upload.ts
|
|
2309
2888
|
async function readForm(req) {
|
|
2310
2889
|
try {
|
|
2311
2890
|
return await req.formData();
|
|
2312
2891
|
} catch {
|
|
2313
|
-
throw new BrandInputError(
|
|
2892
|
+
throw new BrandInputError(
|
|
2893
|
+
'expected multipart/form-data with a "file" field'
|
|
2894
|
+
);
|
|
2314
2895
|
}
|
|
2315
2896
|
}
|
|
2897
|
+
async function contentDigest(file) {
|
|
2898
|
+
const hash = await crypto.subtle.digest("SHA-256", await file.arrayBuffer());
|
|
2899
|
+
return `sha256:${[...new Uint8Array(hash)].map((byte) => byte.toString(16).padStart(2, "0")).join("")}`;
|
|
2900
|
+
}
|
|
2316
2901
|
async function uploadAsset(ctx, req, bookId) {
|
|
2317
2902
|
const book = await loadMemberBook(ctx.db, bookId, ctx.actor.id);
|
|
2318
2903
|
const form = await readForm(req);
|
|
2319
2904
|
const file = form.get("file");
|
|
2320
|
-
if (!(file instanceof File))
|
|
2905
|
+
if (!(file instanceof File))
|
|
2906
|
+
throw new BrandInputError('"file" must be an uploaded file field');
|
|
2321
2907
|
let contentType;
|
|
2322
2908
|
try {
|
|
2323
2909
|
contentType = assertAssetContentType(file.type);
|
|
2324
2910
|
} catch (error) {
|
|
2325
|
-
if (error instanceof BrandInputError)
|
|
2911
|
+
if (error instanceof BrandInputError)
|
|
2912
|
+
return json({ error: error.message }, 415);
|
|
2326
2913
|
throw error;
|
|
2327
2914
|
}
|
|
2328
2915
|
if (file.size > ctx.maxUploadBytes)
|
|
2329
|
-
return json(
|
|
2330
|
-
{ error: `file is ${file.size} bytes; the upload limit is ${ctx.maxUploadBytes}` },
|
|
2331
|
-
413
|
|
2332
|
-
);
|
|
2916
|
+
return json({ error: `file exceeds ${ctx.maxUploadBytes} bytes` }, 413);
|
|
2333
2917
|
const kindRaw = form.get("kind");
|
|
2334
|
-
const kind = typeof kindRaw === "string" && kindRaw
|
|
2918
|
+
const kind = typeof kindRaw === "string" && kindRaw ? kindRaw : "other";
|
|
2335
2919
|
if (!ASSET_KINDS.includes(kind))
|
|
2336
2920
|
throw new BrandInputError(`kind must be one of: ${ASSET_KINDS.join(", ")}`);
|
|
2337
2921
|
const titleRaw = form.get("title");
|
|
2338
|
-
const title = typeof titleRaw === "string" && titleRaw
|
|
2922
|
+
const title = typeof titleRaw === "string" && titleRaw ? capString(titleRaw, "title", 160) : void 0;
|
|
2923
|
+
const fileName = safeFileName(file.name);
|
|
2339
2924
|
const id = ctx.newId();
|
|
2340
|
-
const path = `brand/${book.id}/assets/${id}/${
|
|
2341
|
-
const
|
|
2342
|
-
|
|
2343
|
-
|
|
2925
|
+
const path = `brand/${book.id}/assets/${id}/${fileName}`;
|
|
2926
|
+
const authority = await requireRouteAuthority(
|
|
2927
|
+
ctx,
|
|
2928
|
+
req,
|
|
2929
|
+
"brand.edit",
|
|
2930
|
+
"internal",
|
|
2931
|
+
book.id
|
|
2932
|
+
);
|
|
2933
|
+
const record6 = await ctx.db.storage.upload(
|
|
2934
|
+
path,
|
|
2935
|
+
file,
|
|
2936
|
+
contentType,
|
|
2937
|
+
{ private: true }
|
|
2938
|
+
);
|
|
2939
|
+
if (record6.path !== path) {
|
|
2940
|
+
await ctx.db.storage.delete(record6.path);
|
|
2941
|
+
throw new BrandConflictError(
|
|
2942
|
+
"private storage returned an unexpected asset path"
|
|
2943
|
+
);
|
|
2944
|
+
}
|
|
2945
|
+
try {
|
|
2946
|
+
const current = await requireRouteAuthority(
|
|
2947
|
+
ctx,
|
|
2948
|
+
req,
|
|
2949
|
+
"brand.edit",
|
|
2950
|
+
"internal",
|
|
2951
|
+
book.id
|
|
2952
|
+
);
|
|
2953
|
+
if (current.authorityRef !== authority.authorityRef)
|
|
2954
|
+
throw new BrandConflictError("brand edit authority changed during upload");
|
|
2955
|
+
await ctx.db.transact(createAssetOps({
|
|
2344
2956
|
id,
|
|
2345
2957
|
bookId: book.id,
|
|
2346
2958
|
kind,
|
|
2347
|
-
path:
|
|
2348
|
-
|
|
2959
|
+
path: record6.path,
|
|
2960
|
+
storageObjectId: record6.id,
|
|
2961
|
+
contentDigest: await contentDigest(file),
|
|
2349
2962
|
contentType,
|
|
2350
|
-
size:
|
|
2963
|
+
size: record6.size,
|
|
2351
2964
|
uploadedBy: ctx.actor.id,
|
|
2965
|
+
uploadedAuthorityRef: authority.authorityRef,
|
|
2352
2966
|
audience: book.memberIds,
|
|
2353
2967
|
title,
|
|
2354
2968
|
now: ctx.now()
|
|
2355
|
-
}),
|
|
2356
|
-
|
|
2357
|
-
|
|
2358
|
-
|
|
2359
|
-
|
|
2360
|
-
|
|
2361
|
-
|
|
2362
|
-
|
|
2363
|
-
|
|
2364
|
-
|
|
2365
|
-
|
|
2366
|
-
|
|
2367
|
-
|
|
2368
|
-
|
|
2369
|
-
|
|
2969
|
+
}), {
|
|
2970
|
+
mutationId: `brand:asset-upload:v1:${id}`,
|
|
2971
|
+
guards: [
|
|
2972
|
+
{ ns: BRAND_NS.asset, id, exists: false },
|
|
2973
|
+
{
|
|
2974
|
+
ns: BRAND_NS.book,
|
|
2975
|
+
id: book.id,
|
|
2976
|
+
exists: true,
|
|
2977
|
+
equals: {
|
|
2978
|
+
version: book.version,
|
|
2979
|
+
activationRevision: book.activationRevision,
|
|
2980
|
+
memberIds: book.memberIds
|
|
2981
|
+
}
|
|
2982
|
+
}
|
|
2983
|
+
],
|
|
2984
|
+
asUser: ctx.actor.id,
|
|
2985
|
+
...ctx.actor.email ? { asEmail: ctx.actor.email } : {},
|
|
2986
|
+
asPrincipalKind: "human"
|
|
2370
2987
|
});
|
|
2371
|
-
|
|
2988
|
+
} catch (error) {
|
|
2989
|
+
await ctx.db.storage.delete(record6.path);
|
|
2990
|
+
throw error;
|
|
2372
2991
|
}
|
|
2992
|
+
return json(await linkedAsset(ctx, book, id), 201);
|
|
2993
|
+
}
|
|
2994
|
+
|
|
2995
|
+
// src/routes/assets.ts
|
|
2996
|
+
async function handleAssetsRoot(ctx, req, bookId) {
|
|
2373
2997
|
if (req.method === "POST") return uploadAsset(ctx, req, bookId);
|
|
2374
|
-
return methodNotAllowed();
|
|
2998
|
+
if (req.method !== "GET") return methodNotAllowed();
|
|
2999
|
+
const book = await loadMemberBook(ctx.db, bookId, ctx.actor.id);
|
|
3000
|
+
const result = await ctx.db.query({
|
|
3001
|
+
[BRAND_NS.asset]: {
|
|
3002
|
+
$: { where: { bookId: book.id, status: "live" }, order: { createdAt: "asc" } },
|
|
3003
|
+
book: {}
|
|
3004
|
+
}
|
|
3005
|
+
});
|
|
3006
|
+
const assets = (result[BRAND_NS.asset] ?? []).filter((value) => {
|
|
3007
|
+
const row = value;
|
|
3008
|
+
return row.bookId === book.id && Array.isArray(row.book) && row.book.length === 1 && row.book[0]?.id === book.id;
|
|
3009
|
+
});
|
|
3010
|
+
return json({ assets });
|
|
3011
|
+
}
|
|
3012
|
+
async function handleAssetContent(ctx, req, bookId, assetId) {
|
|
3013
|
+
if (req.method !== "GET") return methodNotAllowed();
|
|
3014
|
+
const book = await loadMemberBook(ctx.db, bookId, ctx.actor.id);
|
|
3015
|
+
const asset = await linkedAsset(ctx, book, assetId);
|
|
3016
|
+
const expiresInSeconds = 300;
|
|
3017
|
+
const url = await ctx.db.storage.sign(asset.path, expiresInSeconds);
|
|
3018
|
+
return json(
|
|
3019
|
+
{ assetId, url, expiresInSeconds },
|
|
3020
|
+
200,
|
|
3021
|
+
{ "cache-control": "private, no-store", "x-content-type-options": "nosniff" }
|
|
3022
|
+
);
|
|
2375
3023
|
}
|
|
2376
3024
|
async function handleAssetItem(ctx, req, bookId, assetId) {
|
|
2377
3025
|
if (req.method !== "DELETE") return methodNotAllowed();
|
|
2378
3026
|
const book = await loadMemberBook(ctx.db, bookId, ctx.actor.id);
|
|
2379
|
-
|
|
2380
|
-
|
|
2381
|
-
|
|
2382
|
-
|
|
2383
|
-
|
|
3027
|
+
let asset = await linkedAsset(ctx, book, assetId, true);
|
|
3028
|
+
const authority = await requireRouteAuthority(
|
|
3029
|
+
ctx,
|
|
3030
|
+
req,
|
|
3031
|
+
"brand.edit",
|
|
3032
|
+
"destructive",
|
|
3033
|
+
book.id
|
|
3034
|
+
);
|
|
3035
|
+
const mutationBase = `brand:asset-delete:v1:${book.id}:${asset.id}`;
|
|
3036
|
+
if (asset.status === "live") {
|
|
3037
|
+
const deletedAt = ctx.now();
|
|
3038
|
+
try {
|
|
3039
|
+
await ctx.db.transact(beginAssetDeleteOps(
|
|
3040
|
+
asset.id,
|
|
3041
|
+
deletedAt,
|
|
3042
|
+
ctx.actor.id,
|
|
3043
|
+
authority.authorityRef
|
|
3044
|
+
), {
|
|
3045
|
+
mutationId: `${mutationBase}:begin`,
|
|
3046
|
+
guards: [
|
|
3047
|
+
{
|
|
3048
|
+
ns: BRAND_NS.asset,
|
|
3049
|
+
id: asset.id,
|
|
3050
|
+
exists: true,
|
|
3051
|
+
equals: {
|
|
3052
|
+
bookId: book.id,
|
|
3053
|
+
path: asset.path,
|
|
3054
|
+
storageObjectId: asset.storageObjectId,
|
|
3055
|
+
contentDigest: asset.contentDigest,
|
|
3056
|
+
status: "live"
|
|
3057
|
+
}
|
|
3058
|
+
},
|
|
3059
|
+
{
|
|
3060
|
+
ns: BRAND_NS.book,
|
|
3061
|
+
id: book.id,
|
|
3062
|
+
exists: true,
|
|
3063
|
+
equals: { version: book.version, memberIds: book.memberIds }
|
|
3064
|
+
}
|
|
3065
|
+
],
|
|
3066
|
+
asUser: ctx.actor.id,
|
|
3067
|
+
...ctx.actor.email ? { asEmail: ctx.actor.email } : {},
|
|
3068
|
+
asPrincipalKind: "human"
|
|
3069
|
+
});
|
|
3070
|
+
} catch (error) {
|
|
3071
|
+
if (typeof error === "object" && error !== null && error.code === "transact_guard_failed") throw new BrandConflictError("asset changed before deletion");
|
|
3072
|
+
throw error;
|
|
3073
|
+
}
|
|
3074
|
+
asset = { ...asset, status: "deleting", deletedAt };
|
|
3075
|
+
}
|
|
2384
3076
|
await ctx.db.storage.delete(asset.path);
|
|
2385
|
-
|
|
2386
|
-
|
|
2387
|
-
|
|
3077
|
+
await ctx.db.transact(finishAssetDeleteOps(asset.id), {
|
|
3078
|
+
mutationId: `${mutationBase}:finish`,
|
|
3079
|
+
guards: [{
|
|
3080
|
+
ns: BRAND_NS.asset,
|
|
3081
|
+
id: asset.id,
|
|
3082
|
+
exists: true,
|
|
3083
|
+
equals: {
|
|
3084
|
+
bookId: book.id,
|
|
3085
|
+
path: asset.path,
|
|
3086
|
+
storageObjectId: asset.storageObjectId,
|
|
3087
|
+
contentDigest: asset.contentDigest,
|
|
3088
|
+
status: "deleting",
|
|
3089
|
+
deletedBy: asset.deletedBy ?? ctx.actor.id,
|
|
3090
|
+
deletedAuthorityRef: asset.deletedAuthorityRef ?? authority.authorityRef
|
|
3091
|
+
}
|
|
3092
|
+
}],
|
|
3093
|
+
asUser: ctx.actor.id,
|
|
3094
|
+
...ctx.actor.email ? { asEmail: ctx.actor.email } : {},
|
|
3095
|
+
asPrincipalKind: "human"
|
|
3096
|
+
});
|
|
3097
|
+
return json({ id: asset.id, status: "deleted", deletedAt: asset.deletedAt });
|
|
2388
3098
|
}
|
|
2389
3099
|
|
|
2390
3100
|
// src/routes/books.ts
|
|
@@ -2397,73 +3107,1036 @@ async function handleBooksRoot(ctx, req) {
|
|
|
2397
3107
|
return json({ books });
|
|
2398
3108
|
}
|
|
2399
3109
|
if (req.method === "POST") {
|
|
3110
|
+
if (ctx.actor.kind !== "human")
|
|
3111
|
+
throw new BrandForbiddenError("only a human can create a brand book");
|
|
2400
3112
|
const body = await readJson(req);
|
|
2401
3113
|
const id = ctx.newId();
|
|
3114
|
+
const authority = await requireRouteAuthority(
|
|
3115
|
+
ctx,
|
|
3116
|
+
req,
|
|
3117
|
+
"brand.edit",
|
|
3118
|
+
"internal"
|
|
3119
|
+
);
|
|
2402
3120
|
const ops = createBookOps({
|
|
2403
3121
|
id,
|
|
2404
3122
|
slug: str(body, "slug"),
|
|
2405
3123
|
name: str(body, "name"),
|
|
2406
3124
|
ownerId: ctx.actor.id,
|
|
3125
|
+
createdAuthorityRef: authority.authorityRef,
|
|
2407
3126
|
memberIds: optStrArray(body, "memberIds") ?? [],
|
|
2408
3127
|
channelId: optStr(body, "channelId"),
|
|
2409
3128
|
now: ctx.now()
|
|
2410
3129
|
});
|
|
2411
|
-
await ctx.db.transact(ops, {
|
|
3130
|
+
await ctx.db.transact(ops, {
|
|
3131
|
+
mutationId: id,
|
|
3132
|
+
guards: [{ ns: BRAND_NS.book, id, exists: false }],
|
|
3133
|
+
asUser: ctx.actor.id,
|
|
3134
|
+
...ctx.actor.email ? { asEmail: ctx.actor.email } : {},
|
|
3135
|
+
asPrincipalKind: "human"
|
|
3136
|
+
});
|
|
2412
3137
|
return json(await loadBook(ctx.db, id), 201);
|
|
2413
3138
|
}
|
|
2414
3139
|
return methodNotAllowed();
|
|
2415
3140
|
}
|
|
3141
|
+
var sameStrings = (left, right) => left.length === right.length && left.every((value, index) => value === right[index]);
|
|
3142
|
+
var guardFailure = (error) => typeof error === "object" && error !== null && error.code === "transact_guard_failed";
|
|
3143
|
+
async function handleBookMembers(ctx, req, bookId) {
|
|
3144
|
+
if (req.method !== "PATCH") return methodNotAllowed();
|
|
3145
|
+
const book = await loadMemberBook(ctx.db, bookId, ctx.actor.id);
|
|
3146
|
+
if (ctx.actor.kind !== "human")
|
|
3147
|
+
throw new BrandForbiddenError("only a human app owner can change members");
|
|
3148
|
+
const body = await readJson(req);
|
|
3149
|
+
const rawMembers = optStrArray(body, "memberIds");
|
|
3150
|
+
const rawExpected = optStrArray(body, "expectedMemberIds");
|
|
3151
|
+
if (!rawMembers || !rawExpected)
|
|
3152
|
+
throw new BrandInputError('"memberIds" and "expectedMemberIds" are required string arrays');
|
|
3153
|
+
const memberIds = Array.from(/* @__PURE__ */ new Set([
|
|
3154
|
+
book.ownerId,
|
|
3155
|
+
...rawMembers.map((id, index) => capString(id, `memberIds[${index}]`, 160))
|
|
3156
|
+
]));
|
|
3157
|
+
if (memberIds.length > 100)
|
|
3158
|
+
throw new BrandInputError("a brand book supports at most 100 members");
|
|
3159
|
+
const expectedMemberIds = rawExpected.map((id, index) => capString(id, `expectedMemberIds[${index}]`, 160));
|
|
3160
|
+
if (!sameStrings(book.memberIds, expectedMemberIds) && !sameStrings(book.memberIds, memberIds))
|
|
3161
|
+
throw new BrandConflictError("brand book membership changed since review");
|
|
3162
|
+
const mutationId = capString(str(body, "mutationId"), "mutationId", 200);
|
|
3163
|
+
const mutationKey = `brand:book-members:v1:${bookId}:${mutationId}`;
|
|
3164
|
+
const authority = await requireRouteAuthority(
|
|
3165
|
+
ctx,
|
|
3166
|
+
req,
|
|
3167
|
+
"app.manage",
|
|
3168
|
+
"internal",
|
|
3169
|
+
bookId
|
|
3170
|
+
);
|
|
3171
|
+
try {
|
|
3172
|
+
const tx = await ctx.db.transact(
|
|
3173
|
+
[{
|
|
3174
|
+
t: "update",
|
|
3175
|
+
ns: BRAND_NS.book,
|
|
3176
|
+
id: bookId,
|
|
3177
|
+
attrs: {
|
|
3178
|
+
memberIds,
|
|
3179
|
+
version: book.version + 1,
|
|
3180
|
+
updatedAt: ctx.now(),
|
|
3181
|
+
rosterAuthorityRef: authority.authorityRef
|
|
3182
|
+
}
|
|
3183
|
+
}],
|
|
3184
|
+
{
|
|
3185
|
+
mutationId: mutationKey,
|
|
3186
|
+
guards: [{
|
|
3187
|
+
ns: BRAND_NS.book,
|
|
3188
|
+
id: bookId,
|
|
3189
|
+
exists: true,
|
|
3190
|
+
equals: { memberIds: expectedMemberIds, version: book.version }
|
|
3191
|
+
}],
|
|
3192
|
+
asUser: ctx.actor.id,
|
|
3193
|
+
...ctx.actor.email ? { asEmail: ctx.actor.email } : {},
|
|
3194
|
+
asPrincipalKind: "human"
|
|
3195
|
+
}
|
|
3196
|
+
);
|
|
3197
|
+
return json({ bookId, memberIds, duplicate: tx.duplicate === true });
|
|
3198
|
+
} catch (error) {
|
|
3199
|
+
if (guardFailure(error))
|
|
3200
|
+
throw new BrandConflictError("brand book membership changed since review");
|
|
3201
|
+
throw error;
|
|
3202
|
+
}
|
|
3203
|
+
}
|
|
2416
3204
|
async function handleBookItem(ctx, req, bookId) {
|
|
2417
3205
|
if (req.method !== "GET") return methodNotAllowed();
|
|
2418
3206
|
return json(await loadMemberBook(ctx.db, bookId, ctx.actor.id));
|
|
2419
3207
|
}
|
|
2420
3208
|
|
|
3209
|
+
// src/routes/proposal-input.ts
|
|
3210
|
+
var SNAPSHOT_KEYS = [
|
|
3211
|
+
"audience",
|
|
3212
|
+
"bookId",
|
|
3213
|
+
"createdAt",
|
|
3214
|
+
"createdAuthorityRef",
|
|
3215
|
+
"createdBy",
|
|
3216
|
+
"id",
|
|
3217
|
+
"kind",
|
|
3218
|
+
"payload",
|
|
3219
|
+
"provenance",
|
|
3220
|
+
"rationale",
|
|
3221
|
+
"reviewDigest",
|
|
3222
|
+
"status"
|
|
3223
|
+
];
|
|
3224
|
+
var record3 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
3225
|
+
function parseProposalResolutionRequest(body, bookId, proposalId) {
|
|
3226
|
+
const allowed = /* @__PURE__ */ new Set([
|
|
3227
|
+
"mutationId",
|
|
3228
|
+
"resolution",
|
|
3229
|
+
"reviewedProposal",
|
|
3230
|
+
"note"
|
|
3231
|
+
]);
|
|
3232
|
+
if (Object.keys(body).some((key) => !allowed.has(key)))
|
|
3233
|
+
throw new BrandInputError("proposal resolution body has unknown fields");
|
|
3234
|
+
const mutationId = capString(str(body, "mutationId"), "mutationId", 200);
|
|
3235
|
+
const rawResolution = str(body, "resolution");
|
|
3236
|
+
if (rawResolution !== "accepted" && rawResolution !== "rejected")
|
|
3237
|
+
throw new BrandInputError('"resolution" must be "accepted" or "rejected"');
|
|
3238
|
+
const rawNote = optStr(body, "note");
|
|
3239
|
+
const resolutionNote = rawNote?.trim() ? capString(rawNote, "note", 1e3) : void 0;
|
|
3240
|
+
return {
|
|
3241
|
+
mutationId,
|
|
3242
|
+
resolution: rawResolution,
|
|
3243
|
+
reviewed: parseReviewedProposal(body.reviewedProposal, bookId, proposalId),
|
|
3244
|
+
...resolutionNote ? { resolutionNote } : {}
|
|
3245
|
+
};
|
|
3246
|
+
}
|
|
3247
|
+
function proposalReviewSnapshot(proposal) {
|
|
3248
|
+
if (proposal.status !== "open")
|
|
3249
|
+
throw new BrandConflictError(`proposal ${proposal.id} is ${proposal.status}, not open`);
|
|
3250
|
+
return {
|
|
3251
|
+
id: proposal.id,
|
|
3252
|
+
bookId: proposal.bookId,
|
|
3253
|
+
kind: proposal.kind,
|
|
3254
|
+
status: "open",
|
|
3255
|
+
payload: proposal.payload,
|
|
3256
|
+
rationale: proposal.rationale,
|
|
3257
|
+
provenance: proposal.provenance,
|
|
3258
|
+
reviewDigest: proposal.reviewDigest,
|
|
3259
|
+
audience: proposal.audience,
|
|
3260
|
+
createdBy: proposal.createdBy,
|
|
3261
|
+
createdAuthorityRef: proposal.createdAuthorityRef,
|
|
3262
|
+
createdAt: proposal.createdAt
|
|
3263
|
+
};
|
|
3264
|
+
}
|
|
3265
|
+
function parseReviewedProposal(value, bookId, proposalId) {
|
|
3266
|
+
if (!isBoundedBrandJson(value, { maxDepth: 12, maxNodes: 1500, maxBytes: 32 * 1024 }))
|
|
3267
|
+
throw new BrandInputError('"reviewedProposal" is too deeply nested, complex, or large');
|
|
3268
|
+
if (!record3(value)) throw new BrandInputError('"reviewedProposal" must be an object');
|
|
3269
|
+
const keys = Object.keys(value).sort();
|
|
3270
|
+
if (keys.length !== SNAPSHOT_KEYS.length || !SNAPSHOT_KEYS.every((key, index) => key === keys[index])) throw new BrandInputError('"reviewedProposal" has an invalid shape');
|
|
3271
|
+
if (value.id !== proposalId || value.bookId !== bookId || value.status !== "open")
|
|
3272
|
+
throw new BrandInputError('"reviewedProposal" must identify this open proposal');
|
|
3273
|
+
if (typeof value.kind !== "string" || !PROPOSAL_KINDS.includes(value.kind) || !record3(value.payload) || typeof value.rationale !== "string" || !Array.isArray(value.audience) || value.audience.length < 1 || value.audience.length > 100 || value.audience.some((id) => typeof id !== "string" || id.length < 1 || id.length > 200) || new Set(value.audience).size !== value.audience.length || typeof value.createdBy !== "string" || value.createdBy.length < 1 || value.createdBy.length > 200 || typeof value.createdAuthorityRef !== "string" || value.createdAuthorityRef.length < 1 || value.createdAuthorityRef.length > 200 || !Number.isSafeInteger(value.createdAt) || value.createdAt < 0 || typeof value.reviewDigest !== "string" || !/^sha256:[0-9a-f]{64}$/.test(value.reviewDigest)) throw new BrandInputError('"reviewedProposal" has invalid fields');
|
|
3274
|
+
if (!record3(value.provenance) || Object.keys(value.provenance).sort().join(",") !== "messageId,sourceAsset,sourceAssetId,taintLabels,turnId" || value.provenance.sourceAssetId !== null && (typeof value.provenance.sourceAssetId !== "string" || value.provenance.sourceAssetId.length < 1 || value.provenance.sourceAssetId.length > 200) || value.provenance.sourceAsset !== null && (!record3(value.provenance.sourceAsset) || Object.keys(value.provenance.sourceAsset).sort().join(",") !== "analysisDigest,analysisRevision,assetId,contentDigest,contentType,objectEtag,objectSize,pathDigest" || typeof value.provenance.sourceAsset.assetId !== "string" || value.provenance.sourceAsset.assetId.length < 1 || value.provenance.sourceAsset.assetId.length > 200 || typeof value.provenance.sourceAsset.objectEtag !== "string" || value.provenance.sourceAsset.objectEtag.length < 1 || value.provenance.sourceAsset.objectEtag.length > 200 || !Number.isSafeInteger(value.provenance.sourceAsset.objectSize) || value.provenance.sourceAsset.objectSize < 1 || typeof value.provenance.sourceAsset.pathDigest !== "string" || !/^sha256:[0-9a-f]{64}$/.test(value.provenance.sourceAsset.pathDigest) || value.provenance.sourceAsset.contentType !== null && (typeof value.provenance.sourceAsset.contentType !== "string" || value.provenance.sourceAsset.contentType.length < 1 || value.provenance.sourceAsset.contentType.length > 160) || typeof value.provenance.sourceAsset.contentDigest !== "string" || !/^sha256:[0-9a-f]{64}$/.test(value.provenance.sourceAsset.contentDigest) || value.provenance.sourceAsset.analysisRevision !== null && (!Number.isSafeInteger(value.provenance.sourceAsset.analysisRevision) || value.provenance.sourceAsset.analysisRevision < 0) || value.provenance.sourceAsset.analysisDigest !== null && (typeof value.provenance.sourceAsset.analysisDigest !== "string" || !/^sha256:[0-9a-f]{64}$/.test(
|
|
3275
|
+
value.provenance.sourceAsset.analysisDigest
|
|
3276
|
+
)) || value.provenance.sourceAsset.assetId !== value.provenance.sourceAssetId) || value.provenance.sourceAssetId === null !== (value.provenance.sourceAsset === null) || value.provenance.messageId !== null && (typeof value.provenance.messageId !== "string" || value.provenance.messageId.length < 1 || value.provenance.messageId.length > 200) || value.provenance.turnId !== null && (typeof value.provenance.turnId !== "string" || value.provenance.turnId.length < 1 || value.provenance.turnId.length > 200) || !Array.isArray(value.provenance.taintLabels) || value.provenance.taintLabels.length > 16 || value.provenance.taintLabels.some((label) => typeof label !== "string" || label.length < 1 || label.length > 120) || new Set(value.provenance.taintLabels).size !== value.provenance.taintLabels.length) throw new BrandInputError('"reviewedProposal.provenance" is invalid');
|
|
3277
|
+
return value;
|
|
3278
|
+
}
|
|
3279
|
+
var proposalGuardEquals = (snapshot) => ({ ...snapshot });
|
|
3280
|
+
|
|
3281
|
+
// src/routes/proposal-effects.ts
|
|
3282
|
+
async function tokenSnapshot(swatches, typography, input, paletteId, paletteReceiptId, paletteActionDigest, typographyReceiptId, typographyActionDigest) {
|
|
3283
|
+
const compiled = compileBrandTokens({ swatches, ...typography ? { typography } : {} });
|
|
3284
|
+
const sources = {
|
|
3285
|
+
paletteId,
|
|
3286
|
+
paletteReceiptId,
|
|
3287
|
+
paletteActionDigest,
|
|
3288
|
+
typographyReceiptId: typographyReceiptId ?? null,
|
|
3289
|
+
typographyActionDigest: typographyActionDigest ?? null
|
|
3290
|
+
};
|
|
3291
|
+
return {
|
|
3292
|
+
light: compiled.light,
|
|
3293
|
+
dark: compiled.dark,
|
|
3294
|
+
warnings: compiled.warnings,
|
|
3295
|
+
compiledAt: input.now,
|
|
3296
|
+
paletteId,
|
|
3297
|
+
paletteReceiptId,
|
|
3298
|
+
paletteActionDigest,
|
|
3299
|
+
...typographyReceiptId ? { typographyReceiptId } : {},
|
|
3300
|
+
...typographyActionDigest ? { typographyActionDigest } : {},
|
|
3301
|
+
sourceDigest: await brandJsonDigest(sources)
|
|
3302
|
+
};
|
|
3303
|
+
}
|
|
3304
|
+
function stampProposal(ops, input) {
|
|
3305
|
+
const op = ops.find(
|
|
3306
|
+
(candidate) => candidate.t === "update" && candidate.ns === BRAND_NS.proposal && candidate.id === input.proposal.id
|
|
3307
|
+
);
|
|
3308
|
+
if (!op || op.t !== "update") throw new Error("proposal resolution op missing");
|
|
3309
|
+
Object.assign(op.attrs, {
|
|
3310
|
+
approvedBy: input.actorId,
|
|
3311
|
+
appliedBy: input.actorId,
|
|
3312
|
+
resolutionReceiptId: input.receiptId,
|
|
3313
|
+
resolutionActionDigest: input.actionDigest
|
|
3314
|
+
});
|
|
3315
|
+
}
|
|
3316
|
+
async function proposalDecisionOps(input) {
|
|
3317
|
+
const common = {
|
|
3318
|
+
proposal: input.proposal,
|
|
3319
|
+
resolvedBy: input.actorId,
|
|
3320
|
+
now: input.now,
|
|
3321
|
+
resolutionNote: input.resolutionNote
|
|
3322
|
+
};
|
|
3323
|
+
if (input.resolution === "rejected") {
|
|
3324
|
+
const ops2 = rejectProposalOps(common);
|
|
3325
|
+
stampProposal(ops2, input);
|
|
3326
|
+
return ops2;
|
|
3327
|
+
}
|
|
3328
|
+
let ops;
|
|
3329
|
+
if (input.proposal.kind === "palette") {
|
|
3330
|
+
if (!input.paletteId) throw new Error("accepted palette id missing");
|
|
3331
|
+
ops = acceptProposalOps({
|
|
3332
|
+
...common,
|
|
3333
|
+
paletteId: input.paletteId,
|
|
3334
|
+
approvalReceiptId: input.receiptId,
|
|
3335
|
+
approvalActionDigest: input.actionDigest
|
|
3336
|
+
});
|
|
3337
|
+
const swatches = assertSwatches(input.proposal.payload.swatches);
|
|
3338
|
+
const tokens = await tokenSnapshot(
|
|
3339
|
+
swatches,
|
|
3340
|
+
input.approvedTypography?.content,
|
|
3341
|
+
input,
|
|
3342
|
+
input.paletteId,
|
|
3343
|
+
input.receiptId,
|
|
3344
|
+
input.actionDigest,
|
|
3345
|
+
input.approvedTypography?.approvalReceiptId,
|
|
3346
|
+
input.approvedTypography?.approvalActionDigest
|
|
3347
|
+
);
|
|
3348
|
+
const bookOp = ops.find(
|
|
3349
|
+
(op) => op.t === "update" && op.ns === BRAND_NS.book && op.id === input.book.id
|
|
3350
|
+
);
|
|
3351
|
+
if (!bookOp || bookOp.t !== "update") throw new Error("book activation op missing");
|
|
3352
|
+
Object.assign(bookOp.attrs, {
|
|
3353
|
+
version: input.book.version + 1,
|
|
3354
|
+
activationRevision: input.book.activationRevision + 1,
|
|
3355
|
+
tokens
|
|
3356
|
+
});
|
|
3357
|
+
if (input.priorPalette && input.priorPalette.id !== input.paletteId) {
|
|
3358
|
+
ops.unshift({
|
|
3359
|
+
t: "update",
|
|
3360
|
+
ns: BRAND_NS.palette,
|
|
3361
|
+
id: input.priorPalette.id,
|
|
3362
|
+
attrs: { status: "archived", updatedAt: input.now }
|
|
3363
|
+
});
|
|
3364
|
+
}
|
|
3365
|
+
} else {
|
|
3366
|
+
const content = input.proposal.payload.content;
|
|
3367
|
+
if (typeof content !== "object" || content === null || Array.isArray(content))
|
|
3368
|
+
throw new BrandInputError("proposal payload.content must be an object");
|
|
3369
|
+
ops = [
|
|
3370
|
+
...upsertSectionOps({
|
|
3371
|
+
bookId: input.book.id,
|
|
3372
|
+
kind: input.proposal.kind,
|
|
3373
|
+
content,
|
|
3374
|
+
status: "approved",
|
|
3375
|
+
audience: input.book.memberIds,
|
|
3376
|
+
updatedBy: input.actorId,
|
|
3377
|
+
approvalReceiptId: input.receiptId,
|
|
3378
|
+
approvalActionDigest: input.actionDigest,
|
|
3379
|
+
now: input.now
|
|
3380
|
+
}),
|
|
3381
|
+
{
|
|
3382
|
+
t: "update",
|
|
3383
|
+
ns: BRAND_NS.book,
|
|
3384
|
+
id: input.book.id,
|
|
3385
|
+
attrs: {
|
|
3386
|
+
activationRevision: input.book.activationRevision + 1,
|
|
3387
|
+
version: input.book.version + 1,
|
|
3388
|
+
updatedAt: input.now
|
|
3389
|
+
}
|
|
3390
|
+
},
|
|
3391
|
+
{
|
|
3392
|
+
t: "update",
|
|
3393
|
+
ns: BRAND_NS.proposal,
|
|
3394
|
+
id: input.proposal.id,
|
|
3395
|
+
attrs: {
|
|
3396
|
+
status: "accepted",
|
|
3397
|
+
resolvedBy: input.actorId,
|
|
3398
|
+
resolvedAt: input.now,
|
|
3399
|
+
...input.resolutionNote ? { resolutionNote: input.resolutionNote } : {}
|
|
3400
|
+
}
|
|
3401
|
+
}
|
|
3402
|
+
];
|
|
3403
|
+
if (input.proposal.kind === "typography" && input.priorPalette) {
|
|
3404
|
+
const bookOp = ops.find(
|
|
3405
|
+
(op) => op.t === "update" && op.ns === BRAND_NS.book && op.id === input.book.id
|
|
3406
|
+
);
|
|
3407
|
+
if (bookOp?.t === "update") {
|
|
3408
|
+
if (!input.priorPalette.approvalReceiptId || !input.priorPalette.approvalActionDigest)
|
|
3409
|
+
throw new BrandInputError("active palette is missing approval provenance");
|
|
3410
|
+
bookOp.attrs.tokens = await tokenSnapshot(
|
|
3411
|
+
input.priorPalette.swatches,
|
|
3412
|
+
content,
|
|
3413
|
+
input,
|
|
3414
|
+
input.priorPalette.id,
|
|
3415
|
+
input.priorPalette.approvalReceiptId,
|
|
3416
|
+
input.priorPalette.approvalActionDigest,
|
|
3417
|
+
input.receiptId,
|
|
3418
|
+
input.actionDigest
|
|
3419
|
+
);
|
|
3420
|
+
}
|
|
3421
|
+
}
|
|
3422
|
+
}
|
|
3423
|
+
stampProposal(ops, input);
|
|
3424
|
+
return ops;
|
|
3425
|
+
}
|
|
3426
|
+
|
|
3427
|
+
// src/routes/proposal-resolution-receipts.ts
|
|
3428
|
+
async function proposalResolutionMutationKey(bookId, proposalId, mutationId) {
|
|
3429
|
+
const digest = await brandJsonDigest({
|
|
3430
|
+
version: 3,
|
|
3431
|
+
bookId,
|
|
3432
|
+
proposalId,
|
|
3433
|
+
mutationId
|
|
3434
|
+
});
|
|
3435
|
+
return `brand:proposal-resolution:v3:${digest.slice("sha256:".length)}`;
|
|
3436
|
+
}
|
|
3437
|
+
async function proposalReceiptByMutation(ctx, mutationKey) {
|
|
3438
|
+
const result = await ctx.db.query({
|
|
3439
|
+
[BRAND_NS.approvalReceipt]: {
|
|
3440
|
+
$: { where: { mutationKey } },
|
|
3441
|
+
book: {}
|
|
3442
|
+
}
|
|
3443
|
+
});
|
|
3444
|
+
const row = (result[BRAND_NS.approvalReceipt] ?? [])[0];
|
|
3445
|
+
if (!row || !Array.isArray(row.book) || row.book.length !== 1 || row.book[0]?.id !== row.bookId) return void 0;
|
|
3446
|
+
const { book: _book, ...receipt2 } = row;
|
|
3447
|
+
return receipt2;
|
|
3448
|
+
}
|
|
3449
|
+
async function consumeProposalAuthority(ctx, req, bookId, proposalId, actionDigest, mutationKey) {
|
|
3450
|
+
const authority = await ctx.consumeHumanExact({
|
|
3451
|
+
req,
|
|
3452
|
+
actor: ctx.actor,
|
|
3453
|
+
appId: ctx.appId,
|
|
3454
|
+
capability: "brand.proposal.resolve",
|
|
3455
|
+
projectCapability: "brand.approve",
|
|
3456
|
+
effect: "internal",
|
|
3457
|
+
resource: { bookId, proposalId },
|
|
3458
|
+
actionDigest,
|
|
3459
|
+
consumptionIdempotencyKey: mutationKey
|
|
3460
|
+
});
|
|
3461
|
+
const resourceDigest = await brandJsonDigest({ bookId, proposalId });
|
|
3462
|
+
if (!isBrandHumanAuthorityConsumption(authority) || authority.actorPrincipalId !== ctx.actor.id || authority.appId !== ctx.appId || authority.actionDigest !== actionDigest || authority.resourceDigest !== resourceDigest || authority.consumptionIdempotencyKey !== mutationKey) throw new BrandForbiddenError(
|
|
3463
|
+
"a verified human-exact brand approval is required"
|
|
3464
|
+
);
|
|
3465
|
+
return authority;
|
|
3466
|
+
}
|
|
3467
|
+
function sameProposalResolutionRequest(receipt2, reviewed, resolution, note, paletteId) {
|
|
3468
|
+
return receipt2.resolution === resolution && (receipt2.resolutionNote ?? null) === (note ?? null) && (receipt2.paletteId ?? null) === (paletteId ?? null) && canonicalBrandJson(receipt2.reviewedProposal) === canonicalBrandJson(reviewed);
|
|
3469
|
+
}
|
|
3470
|
+
|
|
3471
|
+
// src/routes/proposal-dependencies.ts
|
|
3472
|
+
async function receipt(ctx, id, actionDigest, expected) {
|
|
3473
|
+
const result = await ctx.db.query({
|
|
3474
|
+
[BRAND_NS.approvalReceipt]: {
|
|
3475
|
+
$: { where: { id } },
|
|
3476
|
+
book: {}
|
|
3477
|
+
}
|
|
3478
|
+
});
|
|
3479
|
+
const row = (result[BRAND_NS.approvalReceipt] ?? [])[0];
|
|
3480
|
+
if (!row || row.actionDigest !== actionDigest || row.resolution !== "accepted" || row.bookId !== expected.bookId || row.reviewedProposal.bookId !== expected.bookId || row.reviewedProposal.kind !== expected.kind || !Array.isArray(row.book) || row.book.length !== 1 || row.book[0]?.id !== expected.bookId || expected.paletteId !== void 0 && row.paletteId !== expected.paletteId || expected.proposalId !== void 0 && row.proposalId !== expected.proposalId || expected.content !== void 0 && canonicalBrandJson(row.reviewedProposal.payload.content) !== canonicalBrandJson(expected.content)) throw new BrandConflictError(`approval receipt ${id} is invalid`);
|
|
3481
|
+
const { book: _book, ...unhydrated } = row;
|
|
3482
|
+
if (!await verifyBrandApprovalReceipt(unhydrated))
|
|
3483
|
+
throw new BrandConflictError(`approval receipt ${id} is invalid`);
|
|
3484
|
+
return unhydrated;
|
|
3485
|
+
}
|
|
3486
|
+
async function activePalette(ctx, book) {
|
|
3487
|
+
if (!book.activePaletteId) return {};
|
|
3488
|
+
const result = await ctx.db.query({
|
|
3489
|
+
[BRAND_NS.palette]: {
|
|
3490
|
+
$: { where: { id: book.activePaletteId } },
|
|
3491
|
+
book: {}
|
|
3492
|
+
}
|
|
3493
|
+
});
|
|
3494
|
+
const palette = (result[BRAND_NS.palette] ?? [])[0];
|
|
3495
|
+
if (!palette || palette.bookId !== book.id || palette.status !== "active" || !palette.proposalId || !palette.approvalReceiptId || !palette.approvalActionDigest || !Array.isArray(palette.book) || palette.book.length !== 1 || palette.book[0]?.id !== book.id) throw new BrandConflictError(
|
|
3496
|
+
"active palette is missing valid approval provenance"
|
|
3497
|
+
);
|
|
3498
|
+
const approval = await receipt(
|
|
3499
|
+
ctx,
|
|
3500
|
+
palette.approvalReceiptId,
|
|
3501
|
+
palette.approvalActionDigest,
|
|
3502
|
+
{
|
|
3503
|
+
bookId: book.id,
|
|
3504
|
+
kind: "palette",
|
|
3505
|
+
paletteId: palette.id,
|
|
3506
|
+
proposalId: palette.proposalId
|
|
3507
|
+
}
|
|
3508
|
+
);
|
|
3509
|
+
let expectedName;
|
|
3510
|
+
let expectedSwatches;
|
|
3511
|
+
let expectedSeed;
|
|
3512
|
+
try {
|
|
3513
|
+
expectedName = capString(
|
|
3514
|
+
approval.reviewedProposal.payload.name,
|
|
3515
|
+
"reviewedProposal.payload.name",
|
|
3516
|
+
120
|
|
3517
|
+
);
|
|
3518
|
+
expectedSwatches = assertSwatches(
|
|
3519
|
+
approval.reviewedProposal.payload.swatches
|
|
3520
|
+
);
|
|
3521
|
+
expectedSeed = approval.reviewedProposal.payload.seedHex === void 0 ? void 0 : assertHex(
|
|
3522
|
+
approval.reviewedProposal.payload.seedHex,
|
|
3523
|
+
"reviewedProposal.payload.seedHex"
|
|
3524
|
+
);
|
|
3525
|
+
} catch {
|
|
3526
|
+
throw new BrandConflictError("active palette approval payload is invalid");
|
|
3527
|
+
}
|
|
3528
|
+
const expectedSource = approval.reviewedProposal.provenance.sourceAsset ? "extracted" : expectedSeed ? "derived" : "manual";
|
|
3529
|
+
if (palette.proposalId !== approval.proposalId || palette.name !== expectedName || canonicalBrandJson(palette.swatches) !== canonicalBrandJson(expectedSwatches) || (palette.seedHex ?? null) !== (expectedSeed ?? null) || palette.source !== expectedSource || palette.rationale !== approval.reviewedProposal.rationale || canonicalBrandJson(palette.audience) !== canonicalBrandJson(approval.reviewedProposal.audience) || palette.createdAt !== approval.createdAt || palette.updatedAt !== approval.createdAt) throw new BrandConflictError(
|
|
3530
|
+
"active palette does not match its approved proposal"
|
|
3531
|
+
);
|
|
3532
|
+
return { palette, receipt: approval };
|
|
3533
|
+
}
|
|
3534
|
+
async function approvedTypography(ctx, book) {
|
|
3535
|
+
const key = `${book.id}:typography`;
|
|
3536
|
+
const result = await ctx.db.query({
|
|
3537
|
+
[BRAND_NS.section]: {
|
|
3538
|
+
$: { where: { key } },
|
|
3539
|
+
book: {}
|
|
3540
|
+
}
|
|
3541
|
+
});
|
|
3542
|
+
const section = (result[BRAND_NS.section] ?? [])[0];
|
|
3543
|
+
if (!section || section.status !== "approved") return {};
|
|
3544
|
+
if (section.bookId !== book.id || section.kind !== "typography" || !section.approvalReceiptId || !section.approvalActionDigest || !Array.isArray(section.book) || section.book.length !== 1 || section.book[0]?.id !== book.id) throw new BrandConflictError(
|
|
3545
|
+
"approved typography is missing valid approval provenance"
|
|
3546
|
+
);
|
|
3547
|
+
assertSectionContent("typography", section.content);
|
|
3548
|
+
return {
|
|
3549
|
+
section,
|
|
3550
|
+
receipt: await receipt(
|
|
3551
|
+
ctx,
|
|
3552
|
+
section.approvalReceiptId,
|
|
3553
|
+
section.approvalActionDigest,
|
|
3554
|
+
{ bookId: book.id, kind: "typography", content: section.content }
|
|
3555
|
+
)
|
|
3556
|
+
};
|
|
3557
|
+
}
|
|
3558
|
+
var receiptGuard = (value) => ({
|
|
3559
|
+
ns: BRAND_NS.approvalReceipt,
|
|
3560
|
+
id: value.id,
|
|
3561
|
+
exists: true,
|
|
3562
|
+
equals: {
|
|
3563
|
+
actionDigest: value.actionDigest,
|
|
3564
|
+
receiptDigest: value.receiptDigest,
|
|
3565
|
+
resolution: "accepted"
|
|
3566
|
+
}
|
|
3567
|
+
});
|
|
3568
|
+
|
|
3569
|
+
// src/routes/proposal-state.ts
|
|
3570
|
+
async function proposalDecisionState(ctx, req, book, proposal, resolution, receiptId, paletteId) {
|
|
3571
|
+
const needsPalette = resolution === "accepted" && (proposal.kind === "palette" || proposal.kind === "typography");
|
|
3572
|
+
const paletteDep = needsPalette ? await activePalette(ctx, book) : {};
|
|
3573
|
+
const typeDep = resolution === "accepted" && proposal.kind === "palette" ? await approvedTypography(ctx, book) : {};
|
|
3574
|
+
const dependencyGuards = [];
|
|
3575
|
+
const source = proposal.provenance.sourceAsset;
|
|
3576
|
+
let sourceAsset;
|
|
3577
|
+
if (source && resolution === "accepted") {
|
|
3578
|
+
const result = await ctx.db.query({
|
|
3579
|
+
[BRAND_NS.asset]: {
|
|
3580
|
+
$: { where: { id: source.assetId, bookId: book.id } },
|
|
3581
|
+
book: {}
|
|
3582
|
+
}
|
|
3583
|
+
});
|
|
3584
|
+
sourceAsset = (result[BRAND_NS.asset] ?? [])[0];
|
|
3585
|
+
if (!sourceAsset || sourceAsset.id !== source.assetId || sourceAsset.bookId !== book.id || sourceAsset.status !== "live" || sourceAsset.deletedAt !== void 0 || sourceAsset.contentDigest !== source.contentDigest || sourceAsset.size !== source.objectSize || sourceAsset.contentType !== source.contentType || await brandJsonDigest({ appId: ctx.appId, path: sourceAsset.path }) !== source.pathDigest || sourceAsset.analysisRevision !== source.analysisRevision || (sourceAsset.analysisDigest ?? null) !== source.analysisDigest || !Array.isArray(sourceAsset.book) || sourceAsset.book.length !== 1 || sourceAsset.book[0]?.id !== book.id) throw new BrandConflictError(
|
|
3586
|
+
"source asset changed since the proposal was reviewed"
|
|
3587
|
+
);
|
|
3588
|
+
if (!await ctx.verifySourceAssetSnapshot({
|
|
3589
|
+
req,
|
|
3590
|
+
appId: ctx.appId,
|
|
3591
|
+
bookId: book.id,
|
|
3592
|
+
assetId: source.assetId,
|
|
3593
|
+
path: sourceAsset.path,
|
|
3594
|
+
snapshot: source
|
|
3595
|
+
})) throw new BrandConflictError(
|
|
3596
|
+
"source asset bytes changed since the proposal was reviewed"
|
|
3597
|
+
);
|
|
3598
|
+
dependencyGuards.push({
|
|
3599
|
+
ns: BRAND_NS.asset,
|
|
3600
|
+
id: sourceAsset.id,
|
|
3601
|
+
exists: true,
|
|
3602
|
+
equals: {
|
|
3603
|
+
bookId: book.id,
|
|
3604
|
+
status: "live",
|
|
3605
|
+
path: sourceAsset.path,
|
|
3606
|
+
storageObjectId: sourceAsset.storageObjectId,
|
|
3607
|
+
contentDigest: source.contentDigest,
|
|
3608
|
+
contentType: source.contentType,
|
|
3609
|
+
size: source.objectSize,
|
|
3610
|
+
analysisRevision: source.analysisRevision,
|
|
3611
|
+
...source.analysisDigest ? { analysisDigest: source.analysisDigest } : {}
|
|
3612
|
+
}
|
|
3613
|
+
});
|
|
3614
|
+
}
|
|
3615
|
+
if (paletteDep.palette && paletteDep.receipt) {
|
|
3616
|
+
dependencyGuards.push({
|
|
3617
|
+
ns: BRAND_NS.palette,
|
|
3618
|
+
id: paletteDep.palette.id,
|
|
3619
|
+
exists: true,
|
|
3620
|
+
equals: {
|
|
3621
|
+
bookId: book.id,
|
|
3622
|
+
status: "active",
|
|
3623
|
+
name: paletteDep.palette.name,
|
|
3624
|
+
swatches: paletteDep.palette.swatches,
|
|
3625
|
+
seedHex: paletteDep.palette.seedHex ?? null,
|
|
3626
|
+
source: paletteDep.palette.source,
|
|
3627
|
+
rationale: paletteDep.palette.rationale,
|
|
3628
|
+
proposalId: paletteDep.palette.proposalId,
|
|
3629
|
+
approvalReceiptId: paletteDep.palette.approvalReceiptId,
|
|
3630
|
+
approvalActionDigest: paletteDep.palette.approvalActionDigest,
|
|
3631
|
+
audience: paletteDep.palette.audience,
|
|
3632
|
+
createdAt: paletteDep.palette.createdAt,
|
|
3633
|
+
updatedAt: paletteDep.palette.updatedAt
|
|
3634
|
+
}
|
|
3635
|
+
}, receiptGuard(paletteDep.receipt));
|
|
3636
|
+
}
|
|
3637
|
+
if (typeDep.section && typeDep.receipt) {
|
|
3638
|
+
dependencyGuards.push({
|
|
3639
|
+
ns: BRAND_NS.section,
|
|
3640
|
+
id: typeDep.section.id,
|
|
3641
|
+
exists: true,
|
|
3642
|
+
equals: {
|
|
3643
|
+
key: typeDep.section.key,
|
|
3644
|
+
bookId: book.id,
|
|
3645
|
+
kind: "typography",
|
|
3646
|
+
status: "approved",
|
|
3647
|
+
content: typeDep.section.content,
|
|
3648
|
+
approvalReceiptId: typeDep.section.approvalReceiptId,
|
|
3649
|
+
approvalActionDigest: typeDep.section.approvalActionDigest,
|
|
3650
|
+
updatedAt: typeDep.section.updatedAt
|
|
3651
|
+
}
|
|
3652
|
+
}, receiptGuard(typeDep.receipt));
|
|
3653
|
+
}
|
|
3654
|
+
let approvedContent = null;
|
|
3655
|
+
let paletteResult = null;
|
|
3656
|
+
if (resolution === "accepted" && proposal.kind === "palette") {
|
|
3657
|
+
if (!paletteId) throw new BrandNotFoundError("resulting palette id");
|
|
3658
|
+
paletteResult = {
|
|
3659
|
+
id: paletteId,
|
|
3660
|
+
name: capString(proposal.payload.name, "payload.name", 120),
|
|
3661
|
+
swatches: assertSwatches(proposal.payload.swatches),
|
|
3662
|
+
seedHex: proposal.payload.seedHex ?? null,
|
|
3663
|
+
receiptId,
|
|
3664
|
+
archivePaletteId: paletteDep.palette?.id ?? null
|
|
3665
|
+
};
|
|
3666
|
+
} else if (resolution === "accepted") {
|
|
3667
|
+
approvedContent = assertSectionContent(
|
|
3668
|
+
proposal.kind,
|
|
3669
|
+
proposal.payload.content
|
|
3670
|
+
);
|
|
3671
|
+
}
|
|
3672
|
+
const tokenSources = resolution === "accepted" && (proposal.kind === "palette" || proposal.kind === "typography") ? {
|
|
3673
|
+
paletteReceiptId: proposal.kind === "palette" ? receiptId : paletteDep.palette?.approvalReceiptId ?? null,
|
|
3674
|
+
paletteActionDigest: proposal.kind === "palette" ? "this-action" : paletteDep.palette?.approvalActionDigest ?? null,
|
|
3675
|
+
typographyReceiptId: proposal.kind === "typography" ? receiptId : typeDep.section?.approvalReceiptId ?? null,
|
|
3676
|
+
typographyActionDigest: proposal.kind === "typography" ? "this-action" : typeDep.section?.approvalActionDigest ?? null
|
|
3677
|
+
} : null;
|
|
3678
|
+
const effectDescriptor = {
|
|
3679
|
+
proposalId: proposal.id,
|
|
3680
|
+
resolution,
|
|
3681
|
+
nextBookVersion: resolution === "accepted" ? book.version + 1 : null,
|
|
3682
|
+
nextActivationRevision: resolution === "accepted" ? book.activationRevision + 1 : null,
|
|
3683
|
+
resultingActivePaletteId: proposal.kind === "palette" && resolution === "accepted" ? paletteId ?? null : book.activePaletteId ?? null,
|
|
3684
|
+
palette: paletteResult,
|
|
3685
|
+
approvedSection: approvedContent ? { kind: proposal.kind, content: approvedContent, receiptId } : null,
|
|
3686
|
+
tokenSources
|
|
3687
|
+
};
|
|
3688
|
+
const binding = {
|
|
3689
|
+
version: 1,
|
|
3690
|
+
bookVersion: book.version,
|
|
3691
|
+
activationRevision: book.activationRevision,
|
|
3692
|
+
activePaletteId: book.activePaletteId ?? null,
|
|
3693
|
+
memberIdsDigest: await brandJsonDigest(book.memberIds),
|
|
3694
|
+
activePaletteDigest: paletteDep.palette ? await brandJsonDigest({
|
|
3695
|
+
id: paletteDep.palette.id,
|
|
3696
|
+
swatches: paletteDep.palette.swatches,
|
|
3697
|
+
approvalReceiptId: paletteDep.palette.approvalReceiptId,
|
|
3698
|
+
approvalActionDigest: paletteDep.palette.approvalActionDigest
|
|
3699
|
+
}) : null,
|
|
3700
|
+
typographyDigest: typeDep.section ? await brandJsonDigest({
|
|
3701
|
+
key: typeDep.section.key,
|
|
3702
|
+
content: typeDep.section.content,
|
|
3703
|
+
approvalReceiptId: typeDep.section.approvalReceiptId,
|
|
3704
|
+
approvalActionDigest: typeDep.section.approvalActionDigest
|
|
3705
|
+
}) : null,
|
|
3706
|
+
sourceAssetDigest: source ? await brandJsonDigest(source) : null,
|
|
3707
|
+
effectDigest: await brandJsonDigest(effectDescriptor)
|
|
3708
|
+
};
|
|
3709
|
+
return {
|
|
3710
|
+
...paletteDep.palette ? { priorPalette: paletteDep.palette } : {},
|
|
3711
|
+
...typeDep.section ? { approvedTypography: typeDep.section } : {},
|
|
3712
|
+
binding,
|
|
3713
|
+
effectDescriptor,
|
|
3714
|
+
dependencyGuards
|
|
3715
|
+
};
|
|
3716
|
+
}
|
|
3717
|
+
|
|
3718
|
+
// src/routes/proposals.ts
|
|
3719
|
+
var record4 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
3720
|
+
var sameStrings2 = (left, right) => left.length === right.length && left.every((value, index) => value === right[index]);
|
|
3721
|
+
var guardFailure2 = (error) => record4(error) && error.code === "transact_guard_failed";
|
|
3722
|
+
async function handleProposalResolution(ctx, req, bookId, proposalId) {
|
|
3723
|
+
if (req.method !== "POST") return methodNotAllowed();
|
|
3724
|
+
const book = await loadMemberBook(ctx.db, bookId, ctx.actor.id);
|
|
3725
|
+
if (ctx.actor.kind !== "human")
|
|
3726
|
+
throw new BrandForbiddenError("only a directly authenticated human can resolve a proposal");
|
|
3727
|
+
const body = await readJson(req);
|
|
3728
|
+
const {
|
|
3729
|
+
mutationId,
|
|
3730
|
+
resolution,
|
|
3731
|
+
reviewed,
|
|
3732
|
+
resolutionNote
|
|
3733
|
+
} = parseProposalResolutionRequest(body, bookId, proposalId);
|
|
3734
|
+
const mutationKey = await proposalResolutionMutationKey(
|
|
3735
|
+
bookId,
|
|
3736
|
+
proposalId,
|
|
3737
|
+
mutationId
|
|
3738
|
+
);
|
|
3739
|
+
const stable = (await brandJsonDigest({ mutationKey })).slice(7);
|
|
3740
|
+
const receiptId = `brand-receipt-${stable.slice(0, 32)}`;
|
|
3741
|
+
const paletteId = resolution === "accepted" && reviewed.kind === "palette" ? `brand-palette-${stable.slice(32)}` : void 0;
|
|
3742
|
+
const existing = await proposalReceiptByMutation(ctx, mutationKey);
|
|
3743
|
+
if (existing) {
|
|
3744
|
+
if (!await verifyBrandApprovalReceipt(existing) || !sameProposalResolutionRequest(
|
|
3745
|
+
existing,
|
|
3746
|
+
reviewed,
|
|
3747
|
+
resolution,
|
|
3748
|
+
resolutionNote,
|
|
3749
|
+
paletteId
|
|
3750
|
+
)) throw new BrandConflictError("mutationId was used for another decision");
|
|
3751
|
+
const replayed = await consumeProposalAuthority(
|
|
3752
|
+
ctx,
|
|
3753
|
+
req,
|
|
3754
|
+
bookId,
|
|
3755
|
+
proposalId,
|
|
3756
|
+
existing.actionDigest,
|
|
3757
|
+
mutationKey
|
|
3758
|
+
);
|
|
3759
|
+
if (canonicalBrandJson(replayed) !== canonicalBrandJson(existing.authorityConsumption)) throw new BrandConflictError("central approval receipt changed");
|
|
3760
|
+
return json({ receipt: existing, duplicate: true });
|
|
3761
|
+
}
|
|
3762
|
+
const result = await ctx.db.query({
|
|
3763
|
+
[BRAND_NS.proposal]: {
|
|
3764
|
+
$: { where: { id: proposalId, bookId } },
|
|
3765
|
+
book: {}
|
|
3766
|
+
}
|
|
3767
|
+
});
|
|
3768
|
+
const proposal = (result[BRAND_NS.proposal] ?? [])[0];
|
|
3769
|
+
if (!proposal || proposal.bookId !== book.id || !Array.isArray(proposal.book) || proposal.book.length !== 1 || proposal.book[0]?.id !== book.id) throw new BrandNotFoundError(`proposal ${proposalId}`);
|
|
3770
|
+
const current = proposalReviewSnapshot(proposal);
|
|
3771
|
+
const { reviewDigest, ...unsignedReview } = current;
|
|
3772
|
+
if (await brandJsonDigest(unsignedReview) !== reviewDigest || canonicalBrandJson(current) !== canonicalBrandJson(reviewed)) throw new BrandConflictError("proposal changed since review");
|
|
3773
|
+
if (resolution === "accepted" && !sameStrings2(current.audience, book.memberIds))
|
|
3774
|
+
throw new BrandConflictError("book membership changed; review the proposal again");
|
|
3775
|
+
const state = await proposalDecisionState(
|
|
3776
|
+
ctx,
|
|
3777
|
+
req,
|
|
3778
|
+
book,
|
|
3779
|
+
proposal,
|
|
3780
|
+
resolution,
|
|
3781
|
+
receiptId,
|
|
3782
|
+
paletteId
|
|
3783
|
+
);
|
|
3784
|
+
const actionDigest = await brandJsonDigest({
|
|
3785
|
+
version: 1,
|
|
3786
|
+
reviewedProposal: current,
|
|
3787
|
+
resolution,
|
|
3788
|
+
resolutionNote: resolutionNote ?? null,
|
|
3789
|
+
paletteId: paletteId ?? null,
|
|
3790
|
+
decisionBinding: state.binding
|
|
3791
|
+
});
|
|
3792
|
+
const authority = await consumeProposalAuthority(
|
|
3793
|
+
ctx,
|
|
3794
|
+
req,
|
|
3795
|
+
bookId,
|
|
3796
|
+
proposalId,
|
|
3797
|
+
actionDigest,
|
|
3798
|
+
mutationKey
|
|
3799
|
+
);
|
|
3800
|
+
const receiptBase = {
|
|
3801
|
+
version: 1,
|
|
3802
|
+
id: receiptId,
|
|
3803
|
+
mutationKey,
|
|
3804
|
+
bookId,
|
|
3805
|
+
proposalId,
|
|
3806
|
+
resolution,
|
|
3807
|
+
...paletteId ? { paletteId } : {},
|
|
3808
|
+
...resolutionNote ? { resolutionNote } : {},
|
|
3809
|
+
reviewedProposal: current,
|
|
3810
|
+
actionDigest,
|
|
3811
|
+
decisionBinding: state.binding,
|
|
3812
|
+
approvedBy: ctx.actor.id,
|
|
3813
|
+
approvedByKind: "human",
|
|
3814
|
+
appliedBy: ctx.actor.id,
|
|
3815
|
+
appliedByKind: "human",
|
|
3816
|
+
authorityRef: brandAuthorityRef(authority),
|
|
3817
|
+
authorityCapability: "brand.approve",
|
|
3818
|
+
authorityConsumption: authority,
|
|
3819
|
+
createdAt: authority.consumedAt
|
|
3820
|
+
};
|
|
3821
|
+
const receipt2 = {
|
|
3822
|
+
...receiptBase,
|
|
3823
|
+
receiptDigest: await brandJsonDigest(receiptBase)
|
|
3824
|
+
};
|
|
3825
|
+
const ops = await proposalDecisionOps({
|
|
3826
|
+
proposal: { ...proposal, audience: [...book.memberIds] },
|
|
3827
|
+
book,
|
|
3828
|
+
resolution,
|
|
3829
|
+
receiptId,
|
|
3830
|
+
actionDigest,
|
|
3831
|
+
...paletteId ? { paletteId } : {},
|
|
3832
|
+
...state.priorPalette ? { priorPalette: state.priorPalette } : {},
|
|
3833
|
+
...state.approvedTypography ? { approvedTypography: state.approvedTypography } : {},
|
|
3834
|
+
actorId: ctx.actor.id,
|
|
3835
|
+
now: authority.consumedAt,
|
|
3836
|
+
resolutionNote
|
|
3837
|
+
});
|
|
3838
|
+
ops.push({
|
|
3839
|
+
t: "update",
|
|
3840
|
+
ns: BRAND_NS.approvalReceipt,
|
|
3841
|
+
id: receipt2.id,
|
|
3842
|
+
attrs: receipt2
|
|
3843
|
+
}, {
|
|
3844
|
+
t: "link",
|
|
3845
|
+
ns: BRAND_NS.approvalReceipt,
|
|
3846
|
+
id: receipt2.id,
|
|
3847
|
+
label: "book",
|
|
3848
|
+
target: bookId
|
|
3849
|
+
});
|
|
3850
|
+
const guards = [
|
|
3851
|
+
{
|
|
3852
|
+
ns: BRAND_NS.proposal,
|
|
3853
|
+
id: proposalId,
|
|
3854
|
+
exists: true,
|
|
3855
|
+
equals: proposalGuardEquals(current)
|
|
3856
|
+
},
|
|
3857
|
+
{ ns: BRAND_NS.approvalReceipt, id: receiptId, exists: false },
|
|
3858
|
+
{
|
|
3859
|
+
ns: BRAND_NS.book,
|
|
3860
|
+
id: bookId,
|
|
3861
|
+
exists: true,
|
|
3862
|
+
equals: {
|
|
3863
|
+
version: book.version,
|
|
3864
|
+
activationRevision: book.activationRevision,
|
|
3865
|
+
memberIds: book.memberIds
|
|
3866
|
+
}
|
|
3867
|
+
},
|
|
3868
|
+
...state.dependencyGuards
|
|
3869
|
+
];
|
|
3870
|
+
if (paletteId) {
|
|
3871
|
+
guards.push({ ns: BRAND_NS.palette, id: paletteId, exists: false });
|
|
3872
|
+
}
|
|
3873
|
+
try {
|
|
3874
|
+
const tx = await ctx.db.transact(ops, {
|
|
3875
|
+
mutationId: mutationKey,
|
|
3876
|
+
guards,
|
|
3877
|
+
asUser: ctx.actor.id,
|
|
3878
|
+
...ctx.actor.email ? { asEmail: ctx.actor.email } : {},
|
|
3879
|
+
asPrincipalKind: "human"
|
|
3880
|
+
});
|
|
3881
|
+
if (tx.duplicate) {
|
|
3882
|
+
const replay = await proposalReceiptByMutation(ctx, mutationKey);
|
|
3883
|
+
if (!replay || !await verifyBrandApprovalReceipt(replay) || !sameProposalResolutionRequest(
|
|
3884
|
+
replay,
|
|
3885
|
+
reviewed,
|
|
3886
|
+
resolution,
|
|
3887
|
+
resolutionNote,
|
|
3888
|
+
paletteId
|
|
3889
|
+
) || canonicalBrandJson(replay.authorityConsumption) !== canonicalBrandJson(authority))
|
|
3890
|
+
throw new BrandConflictError("proposal decision replay is invalid");
|
|
3891
|
+
return json({ receipt: replay, duplicate: true });
|
|
3892
|
+
}
|
|
3893
|
+
return json({ receipt: receipt2, duplicate: false });
|
|
3894
|
+
} catch (error) {
|
|
3895
|
+
if (guardFailure2(error)) throw new BrandReviewStateChangedError();
|
|
3896
|
+
throw error;
|
|
3897
|
+
}
|
|
3898
|
+
}
|
|
3899
|
+
|
|
2421
3900
|
// src/routes/tokens.ts
|
|
2422
|
-
var
|
|
2423
|
-
|
|
3901
|
+
var record5 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
3902
|
+
var missing = (bookId) => {
|
|
3903
|
+
throw new BrandNotFoundError(`compiled tokens for brand book ${bookId}`);
|
|
3904
|
+
};
|
|
3905
|
+
async function acceptedReceipt(db, bookId, receiptId, actionDigest) {
|
|
3906
|
+
const result = await db.query({
|
|
3907
|
+
[BRAND_NS.approvalReceipt]: {
|
|
3908
|
+
$: { where: { id: receiptId, bookId } },
|
|
3909
|
+
book: {}
|
|
3910
|
+
}
|
|
3911
|
+
});
|
|
3912
|
+
const receipt2 = (result[BRAND_NS.approvalReceipt] ?? [])[0];
|
|
3913
|
+
if (!receipt2 || receipt2.resolution !== "accepted" || receipt2.actionDigest !== actionDigest || !Array.isArray(receipt2.book) || receipt2.book.length !== 1 || receipt2.book[0]?.id !== bookId) return missing(bookId);
|
|
3914
|
+
const { book: _book, ...unhydrated } = receipt2;
|
|
3915
|
+
if (!await verifyBrandApprovalReceipt(unhydrated)) return missing(bookId);
|
|
3916
|
+
return unhydrated;
|
|
3917
|
+
}
|
|
3918
|
+
async function approvedCache(db, book) {
|
|
2424
3919
|
const cache = book.tokens;
|
|
2425
|
-
if (cache
|
|
2426
|
-
|
|
2427
|
-
|
|
2428
|
-
|
|
2429
|
-
|
|
3920
|
+
if (!cache || !record5(cache.light) || !record5(cache.dark) || !book.activePaletteId || cache.paletteId !== book.activePaletteId || !cache.paletteReceiptId || !cache.paletteActionDigest || !cache.sourceDigest) return missing(book.id);
|
|
3921
|
+
const paletteResult = await db.query({
|
|
3922
|
+
[BRAND_NS.palette]: {
|
|
3923
|
+
$: { where: { id: cache.paletteId, bookId: book.id } },
|
|
3924
|
+
book: {}
|
|
3925
|
+
}
|
|
3926
|
+
});
|
|
3927
|
+
const palette = (paletteResult[BRAND_NS.palette] ?? [])[0];
|
|
3928
|
+
if (!palette || palette.status !== "active" || palette.approvalReceiptId !== cache.paletteReceiptId || palette.approvalActionDigest !== cache.paletteActionDigest || !Array.isArray(palette.book) || palette.book.length !== 1 || palette.book[0]?.id !== book.id) return missing(book.id);
|
|
3929
|
+
await acceptedReceipt(
|
|
3930
|
+
db,
|
|
3931
|
+
book.id,
|
|
3932
|
+
cache.paletteReceiptId,
|
|
3933
|
+
cache.paletteActionDigest
|
|
3934
|
+
);
|
|
3935
|
+
const hasTypeId = cache.typographyReceiptId !== void 0;
|
|
3936
|
+
const hasTypeDigest = cache.typographyActionDigest !== void 0;
|
|
3937
|
+
if (hasTypeId !== hasTypeDigest) return missing(book.id);
|
|
3938
|
+
const sectionResult = await db.query({
|
|
3939
|
+
[BRAND_NS.section]: {
|
|
3940
|
+
$: { where: { key: `${book.id}:typography` } },
|
|
3941
|
+
book: {}
|
|
3942
|
+
}
|
|
2430
3943
|
});
|
|
2431
|
-
const
|
|
2432
|
-
if (
|
|
2433
|
-
|
|
2434
|
-
|
|
3944
|
+
const typography = (sectionResult[BRAND_NS.section] ?? [])[0];
|
|
3945
|
+
if (hasTypeId) {
|
|
3946
|
+
if (!typography || typography.bookId !== book.id || typography.kind !== "typography" || typography.status !== "approved" || typography.approvalReceiptId !== cache.typographyReceiptId || typography.approvalActionDigest !== cache.typographyActionDigest || !Array.isArray(typography.book) || typography.book.length !== 1 || typography.book[0]?.id !== book.id) return missing(book.id);
|
|
3947
|
+
await acceptedReceipt(
|
|
3948
|
+
db,
|
|
3949
|
+
book.id,
|
|
3950
|
+
cache.typographyReceiptId,
|
|
3951
|
+
cache.typographyActionDigest
|
|
3952
|
+
);
|
|
3953
|
+
} else if (typography?.status === "approved" && (typography.approvalReceiptId || typography.approvalActionDigest)) {
|
|
3954
|
+
return missing(book.id);
|
|
3955
|
+
}
|
|
3956
|
+
const sourceDigest = await brandJsonDigest({
|
|
3957
|
+
paletteId: cache.paletteId,
|
|
3958
|
+
paletteReceiptId: cache.paletteReceiptId,
|
|
3959
|
+
paletteActionDigest: cache.paletteActionDigest,
|
|
3960
|
+
typographyReceiptId: cache.typographyReceiptId ?? null,
|
|
3961
|
+
typographyActionDigest: cache.typographyActionDigest ?? null
|
|
2435
3962
|
});
|
|
2436
|
-
|
|
2437
|
-
|
|
2438
|
-
return { light: compiled.light, dark: compiled.dark };
|
|
3963
|
+
if (sourceDigest !== cache.sourceDigest) return missing(book.id);
|
|
3964
|
+
return cache;
|
|
2439
3965
|
}
|
|
2440
3966
|
async function handleTokens(db, req, bookId, which, actor) {
|
|
2441
3967
|
if (req.method !== "GET") return methodNotAllowed();
|
|
2442
3968
|
const book = actor === null ? await loadBook(db, bookId) : await loadMemberBook(db, bookId, actor.id);
|
|
2443
|
-
const
|
|
2444
|
-
|
|
2445
|
-
const
|
|
2446
|
-
|
|
2447
|
-
|
|
2448
|
-
|
|
2449
|
-
return new Response(renderTokensCss(
|
|
3969
|
+
const cache = await approvedCache(db, book);
|
|
3970
|
+
const light = cache.light;
|
|
3971
|
+
const dark = cache.dark;
|
|
3972
|
+
if (which === "tokens.json") return json({ light, dark });
|
|
3973
|
+
const etag = `"brand-tokens-${book.activationRevision}-${cache.sourceDigest.slice(7)}"`;
|
|
3974
|
+
if (req.headers.get("if-none-match")?.split(",").map((value) => value.trim()).includes(etag)) return new Response(null, { status: 304, headers: { etag } });
|
|
3975
|
+
return new Response(renderTokensCss(light, { dark }), {
|
|
2450
3976
|
status: 200,
|
|
2451
|
-
headers: {
|
|
3977
|
+
headers: {
|
|
3978
|
+
"content-type": "text/css; charset=utf-8",
|
|
3979
|
+
"cache-control": "public, max-age=0, must-revalidate",
|
|
3980
|
+
etag
|
|
3981
|
+
}
|
|
3982
|
+
});
|
|
3983
|
+
}
|
|
3984
|
+
|
|
3985
|
+
// src/routes/discussion-references.ts
|
|
3986
|
+
var capLimit = (raw) => {
|
|
3987
|
+
const parsed = Number(raw ?? 20);
|
|
3988
|
+
return Number.isInteger(parsed) ? Math.max(1, Math.min(parsed, 20)) : 20;
|
|
3989
|
+
};
|
|
3990
|
+
var targetFromQuery = (url) => {
|
|
3991
|
+
const kind = url.searchParams.get("kind");
|
|
3992
|
+
const id = url.searchParams.get("id");
|
|
3993
|
+
if (!kind && !id) return null;
|
|
3994
|
+
if (!kind || !id) return null;
|
|
3995
|
+
const value = new URL("https://brand.invalid");
|
|
3996
|
+
value.searchParams.set("odla-ref", `${kind}/${id}`);
|
|
3997
|
+
return parseBrandDiscussionReference(value);
|
|
3998
|
+
};
|
|
3999
|
+
var childNamespace = (kind) => ({
|
|
4000
|
+
"brand:asset": BRAND_NS.asset,
|
|
4001
|
+
"brand:palette": BRAND_NS.palette,
|
|
4002
|
+
"brand:proposal": BRAND_NS.proposal,
|
|
4003
|
+
"brand:receipt": BRAND_NS.approvalReceipt
|
|
4004
|
+
})[kind] ?? null;
|
|
4005
|
+
var childTarget = (kind, row) => ({
|
|
4006
|
+
kind,
|
|
4007
|
+
bookId: row.bookId,
|
|
4008
|
+
resourceId: row.id
|
|
4009
|
+
});
|
|
4010
|
+
var projection = (req, ctx, book, target, row) => {
|
|
4011
|
+
let label = book.name;
|
|
4012
|
+
let hint = `${book.status} brand book`;
|
|
4013
|
+
let summary = `${book.name} brand book`;
|
|
4014
|
+
let status = book.status;
|
|
4015
|
+
let destination = "Brand Studio \xB7 Brand book";
|
|
4016
|
+
if (target.kind === "brand:asset") {
|
|
4017
|
+
const asset = row;
|
|
4018
|
+
label = asset.title?.trim() || `${asset.kind} asset`;
|
|
4019
|
+
hint = `${book.name} \xB7 ${asset.status}`;
|
|
4020
|
+
summary = `${asset.kind} source asset in ${book.name}`;
|
|
4021
|
+
status = asset.status;
|
|
4022
|
+
destination = "Brand Studio \xB7 Conversation";
|
|
4023
|
+
} else if (target.kind === "brand:palette") {
|
|
4024
|
+
const palette = row;
|
|
4025
|
+
label = palette.name;
|
|
4026
|
+
hint = `${book.name} \xB7 ${palette.status} palette`;
|
|
4027
|
+
summary = `${palette.swatches.length}-color palette in ${book.name}`;
|
|
4028
|
+
status = palette.status;
|
|
4029
|
+
destination = "Brand Studio \xB7 Review changes";
|
|
4030
|
+
} else if (target.kind === "brand:proposal") {
|
|
4031
|
+
const proposal = row;
|
|
4032
|
+
label = `${proposal.kind} proposal`;
|
|
4033
|
+
hint = `${book.name} \xB7 ${proposal.status}`;
|
|
4034
|
+
summary = proposal.rationale;
|
|
4035
|
+
status = proposal.status;
|
|
4036
|
+
destination = "Brand Studio \xB7 Review changes";
|
|
4037
|
+
} else if (target.kind === "brand:receipt") {
|
|
4038
|
+
const receipt2 = row;
|
|
4039
|
+
label = `${receipt2.resolution} ${receipt2.reviewedProposal.kind} change`;
|
|
4040
|
+
hint = `${book.name} \xB7 approval receipt`;
|
|
4041
|
+
summary = `${receipt2.resolution} ${receipt2.reviewedProposal.kind} decision`;
|
|
4042
|
+
status = receipt2.resolution;
|
|
4043
|
+
destination = "Brand Studio \xB7 Review changes";
|
|
4044
|
+
}
|
|
4045
|
+
return {
|
|
4046
|
+
kind: target.kind,
|
|
4047
|
+
id: brandDiscussionReferenceId(target),
|
|
4048
|
+
label,
|
|
4049
|
+
hint,
|
|
4050
|
+
summary,
|
|
4051
|
+
status,
|
|
4052
|
+
destination,
|
|
4053
|
+
href: brandDiscussionReferenceHref(
|
|
4054
|
+
new URL(req.url),
|
|
4055
|
+
target,
|
|
4056
|
+
ctx.discussionBasePath
|
|
4057
|
+
)
|
|
4058
|
+
};
|
|
4059
|
+
};
|
|
4060
|
+
async function exact(ctx, req, target) {
|
|
4061
|
+
const book = await loadMemberBook(ctx.db, target.bookId, ctx.actor.id).catch(() => null);
|
|
4062
|
+
if (!book) return [];
|
|
4063
|
+
if (target.kind === "brand:book") {
|
|
4064
|
+
return [projection(req, ctx, book, target, book)];
|
|
4065
|
+
}
|
|
4066
|
+
const namespace = childNamespace(target.kind);
|
|
4067
|
+
if (!namespace || !target.resourceId) return [];
|
|
4068
|
+
const result = await ctx.db.query({
|
|
4069
|
+
[namespace]: {
|
|
4070
|
+
$: { where: { id: target.resourceId }, limit: 1 }
|
|
4071
|
+
}
|
|
4072
|
+
});
|
|
4073
|
+
const row = (result[namespace] ?? [])[0];
|
|
4074
|
+
if (!row || row.bookId !== book.id || target.kind === "brand:asset" && row.status !== "live") return [];
|
|
4075
|
+
return [projection(req, ctx, book, target, row)];
|
|
4076
|
+
}
|
|
4077
|
+
var matches = (item, needle) => !needle || `${item.label}
|
|
4078
|
+
${item.hint}
|
|
4079
|
+
${item.id}`.toLowerCase().includes(needle);
|
|
4080
|
+
async function search(ctx, req, url) {
|
|
4081
|
+
const result = await ctx.db.query({
|
|
4082
|
+
[BRAND_NS.book]: { $: { order: { updatedAt: "desc" }, limit: 100 } },
|
|
4083
|
+
[BRAND_NS.asset]: { $: { order: { createdAt: "desc" }, limit: 100 } },
|
|
4084
|
+
[BRAND_NS.palette]: { $: { order: { updatedAt: "desc" }, limit: 100 } },
|
|
4085
|
+
[BRAND_NS.proposal]: { $: { order: { createdAt: "desc" }, limit: 100 } },
|
|
4086
|
+
[BRAND_NS.approvalReceipt]: {
|
|
4087
|
+
$: { order: { createdAt: "desc" }, limit: 100 }
|
|
4088
|
+
}
|
|
4089
|
+
});
|
|
4090
|
+
const books = (result[BRAND_NS.book] ?? []).filter((book) => isMember(book, ctx.actor.id));
|
|
4091
|
+
const bookById = new Map(books.map((book) => [book.id, book]));
|
|
4092
|
+
const items = books.map(
|
|
4093
|
+
(book) => projection(req, ctx, book, { kind: "brand:book", bookId: book.id }, book)
|
|
4094
|
+
);
|
|
4095
|
+
const append = (kind, rows) => {
|
|
4096
|
+
for (const row of rows) {
|
|
4097
|
+
const book = bookById.get(row.bookId);
|
|
4098
|
+
if (!book || kind === "brand:asset" && row.status !== "live") continue;
|
|
4099
|
+
items.push(projection(req, ctx, book, childTarget(kind, row), row));
|
|
4100
|
+
}
|
|
4101
|
+
};
|
|
4102
|
+
append("brand:asset", result[BRAND_NS.asset] ?? []);
|
|
4103
|
+
append("brand:palette", result[BRAND_NS.palette] ?? []);
|
|
4104
|
+
append("brand:proposal", result[BRAND_NS.proposal] ?? []);
|
|
4105
|
+
append("brand:receipt", result[BRAND_NS.approvalReceipt] ?? []);
|
|
4106
|
+
const needle = (url.searchParams.get("q") ?? "").trim().toLowerCase().slice(0, 120);
|
|
4107
|
+
return items.filter((item) => matches(item, needle)).slice(0, capLimit(url.searchParams.get("limit")));
|
|
4108
|
+
}
|
|
4109
|
+
async function handleBrandDiscussionReferences(ctx, req, url) {
|
|
4110
|
+
if (req.method !== "GET") return methodNotAllowed();
|
|
4111
|
+
const kind = url.searchParams.get("kind");
|
|
4112
|
+
const id = url.searchParams.get("id");
|
|
4113
|
+
if (kind && !id || !kind && id) return json({ error: "kind and id must be paired" }, 400);
|
|
4114
|
+
const target = targetFromQuery(url);
|
|
4115
|
+
if ((kind || id) && !target) return json({ items: [] });
|
|
4116
|
+
return json({
|
|
4117
|
+
items: target ? await exact(ctx, req, target) : await search(ctx, req, url)
|
|
2452
4118
|
});
|
|
2453
4119
|
}
|
|
2454
4120
|
|
|
2455
4121
|
// src/routes/index.ts
|
|
2456
4122
|
var tokensFile = (seg) => seg.length === 3 && seg[0] === "books" && (seg[2] === "tokens.css" || seg[2] === "tokens.json") ? seg[2] : null;
|
|
2457
|
-
async function route(ctx, req, seg) {
|
|
2458
|
-
const [head, id, sub, subId] = seg;
|
|
4123
|
+
async function route(ctx, req, url, seg) {
|
|
4124
|
+
const [head, id, sub, subId, action] = seg;
|
|
4125
|
+
if (head === "discussion-references" && seg.length === 1)
|
|
4126
|
+
return handleBrandDiscussionReferences(ctx, req, url);
|
|
2459
4127
|
if (head !== "books") return null;
|
|
2460
4128
|
if (seg.length === 1) return handleBooksRoot(ctx, req);
|
|
2461
4129
|
if (seg.length === 2) return handleBookItem(ctx, req, id);
|
|
4130
|
+
if (seg.length === 3 && sub === "members") return handleBookMembers(ctx, req, id);
|
|
2462
4131
|
if (sub === "assets") {
|
|
2463
4132
|
if (seg.length === 3) return handleAssetsRoot(ctx, req, id);
|
|
2464
4133
|
if (seg.length === 4) return handleAssetItem(ctx, req, id, subId);
|
|
4134
|
+
if (seg.length === 5 && action === "content")
|
|
4135
|
+
return handleAssetContent(ctx, req, id, subId);
|
|
2465
4136
|
return null;
|
|
2466
4137
|
}
|
|
4138
|
+
if (sub === "proposals" && seg.length === 5 && action === "resolve")
|
|
4139
|
+
return handleProposalResolution(ctx, req, id, subId);
|
|
2467
4140
|
const file = tokensFile(seg);
|
|
2468
4141
|
if (file) return handleTokens(ctx.db, req, id, file, ctx.actor);
|
|
2469
4142
|
return null;
|
|
@@ -2473,9 +4146,14 @@ function createBrandRoutes(options) {
|
|
|
2473
4146
|
const publicTokens = options.publicTokens === true;
|
|
2474
4147
|
const base = {
|
|
2475
4148
|
db: options.db,
|
|
4149
|
+
appId: options.appId,
|
|
4150
|
+
authorizeCapability: options.authorizeCapability,
|
|
4151
|
+
consumeHumanExact: options.consumeHumanExact,
|
|
4152
|
+
verifySourceAssetSnapshot: options.verifySourceAssetSnapshot,
|
|
2476
4153
|
now: options.now ?? Date.now,
|
|
2477
4154
|
newId: options.newId ?? (() => crypto.randomUUID()),
|
|
2478
|
-
maxUploadBytes: options.maxUploadBytes ?? 8 * 1024 * 1024
|
|
4155
|
+
maxUploadBytes: options.maxUploadBytes ?? 8 * 1024 * 1024,
|
|
4156
|
+
discussionBasePath: options.discussionBasePath ?? "/"
|
|
2479
4157
|
};
|
|
2480
4158
|
return async (req) => {
|
|
2481
4159
|
const url = new URL(req.url);
|
|
@@ -2484,15 +4162,44 @@ function createBrandRoutes(options) {
|
|
|
2484
4162
|
try {
|
|
2485
4163
|
const file = tokensFile(seg);
|
|
2486
4164
|
if (publicTokens && file) return await handleTokens(base.db, req, seg[1], file, null);
|
|
2487
|
-
const actor = await options.authorize(req);
|
|
4165
|
+
const actor = await (seg[0] === "discussion-references" ? options.authorizeDiscussionReferences ?? options.authorize : options.authorize)(req);
|
|
2488
4166
|
if (!actor) return json({ error: "unauthorized" }, 401);
|
|
2489
|
-
return await route({ ...base, actor }, req, seg) ?? json({ error: "not found" }, 404);
|
|
4167
|
+
return await route({ ...base, actor }, req, url, seg) ?? json({ error: "not found" }, 404);
|
|
2490
4168
|
} catch (error) {
|
|
2491
4169
|
return errorResponse(error);
|
|
2492
4170
|
}
|
|
2493
4171
|
};
|
|
2494
4172
|
}
|
|
2495
4173
|
|
|
4174
|
+
// src/dispatch-assets.ts
|
|
4175
|
+
var MAX_MESSAGE_ASSETS = 12;
|
|
4176
|
+
function candidateAssetIds(row) {
|
|
4177
|
+
const standard = Array.isArray(row.attachments) ? row.attachments.flatMap((value) => {
|
|
4178
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
4179
|
+
return [];
|
|
4180
|
+
const id = value.id;
|
|
4181
|
+
return typeof id === "string" && id.length > 0 && id.length <= 200 ? [id] : [];
|
|
4182
|
+
}) : [];
|
|
4183
|
+
const legacy = Array.isArray(row.assetIds) ? row.assetIds.filter((value) => typeof value === "string" && value.length > 0 && value.length <= 200) : [];
|
|
4184
|
+
return [.../* @__PURE__ */ new Set([...standard, ...legacy])].slice(0, MAX_MESSAGE_ASSETS);
|
|
4185
|
+
}
|
|
4186
|
+
async function attachedBrandAssetIds(db, book, row) {
|
|
4187
|
+
const candidates = candidateAssetIds(row);
|
|
4188
|
+
const assets = await Promise.all(candidates.map(async (id) => {
|
|
4189
|
+
const result = await db.query({
|
|
4190
|
+
[BRAND_NS.asset]: {
|
|
4191
|
+
$: { where: { id, bookId: book.id, status: "live" }, limit: 2 },
|
|
4192
|
+
book: { $: { limit: 2 } }
|
|
4193
|
+
}
|
|
4194
|
+
});
|
|
4195
|
+
const rows = result[BRAND_NS.asset] ?? [];
|
|
4196
|
+
if (rows.length !== 1) return null;
|
|
4197
|
+
const asset = rows[0];
|
|
4198
|
+
return asset.id === id && asset.bookId === book.id && asset.status === "live" && asset.deletedAt === void 0 && Array.isArray(asset.book) && asset.book.length === 1 && asset.book[0]?.id === book.id ? id : null;
|
|
4199
|
+
}));
|
|
4200
|
+
return assets.filter((id) => id !== null);
|
|
4201
|
+
}
|
|
4202
|
+
|
|
2496
4203
|
// src/triggers.ts
|
|
2497
4204
|
var CHAT_MESSAGE_NS = "chat_message";
|
|
2498
4205
|
var celString = (value) => value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
@@ -2514,73 +4221,133 @@ function brandBotTrigger(opts) {
|
|
|
2514
4221
|
// src/dispatch.ts
|
|
2515
4222
|
function parseBrandDispatch(raw) {
|
|
2516
4223
|
if (!raw || typeof raw !== "object") return null;
|
|
2517
|
-
const
|
|
2518
|
-
const
|
|
2519
|
-
const
|
|
2520
|
-
if (typeof
|
|
2521
|
-
if (!t || typeof t.runAs?.agentId !== "string" || typeof t.runAs?.persona !== "string") return null;
|
|
2522
|
-
if (!e || typeof e.id !== "string" || !e.row || typeof e.row !== "object") return null;
|
|
4224
|
+
const body = raw;
|
|
4225
|
+
const trigger = body.trigger;
|
|
4226
|
+
const event = body.event;
|
|
4227
|
+
if (body.v !== 2 || typeof body.appId !== "string" || !body.appId || typeof body.appIncarnation !== "string" || !/^[0-9a-f]{32}$/.test(body.appIncarnation) || typeof body.jobId !== "string" || !body.jobId || !trigger || typeof trigger.runAs?.agentId !== "string" || !trigger.runAs.agentId || typeof trigger.runAs.persona !== "string" || !event || typeof event.id !== "string" || !event.row || typeof event.row !== "object" || Array.isArray(event.row)) return null;
|
|
2523
4228
|
return {
|
|
2524
|
-
v:
|
|
2525
|
-
appId:
|
|
4229
|
+
v: 2,
|
|
4230
|
+
appId: body.appId,
|
|
4231
|
+
appIncarnation: body.appIncarnation,
|
|
4232
|
+
jobId: body.jobId,
|
|
2526
4233
|
trigger: {
|
|
2527
|
-
id: typeof
|
|
2528
|
-
skill: typeof
|
|
2529
|
-
runAs: {
|
|
2530
|
-
|
|
4234
|
+
id: typeof trigger.id === "string" ? trigger.id : "",
|
|
4235
|
+
skill: typeof trigger.skill === "string" ? trigger.skill : "",
|
|
4236
|
+
runAs: {
|
|
4237
|
+
agentId: trigger.runAs.agentId,
|
|
4238
|
+
persona: trigger.runAs.persona
|
|
4239
|
+
},
|
|
4240
|
+
...typeof trigger.maxDepth === "number" ? { maxDepth: trigger.maxDepth } : {}
|
|
2531
4241
|
},
|
|
2532
|
-
event: {
|
|
4242
|
+
event: {
|
|
4243
|
+
ns: typeof event.ns === "string" ? event.ns : CHAT_MESSAGE_NS,
|
|
4244
|
+
id: event.id,
|
|
4245
|
+
row: event.row
|
|
4246
|
+
}
|
|
2533
4247
|
};
|
|
2534
4248
|
}
|
|
2535
4249
|
async function bookForChannel(db, channelId) {
|
|
2536
4250
|
if (!channelId) return null;
|
|
2537
|
-
const
|
|
2538
|
-
|
|
4251
|
+
const result = await db.query({
|
|
4252
|
+
[BRAND_NS.book]: { $: { where: { channelId } } }
|
|
4253
|
+
});
|
|
4254
|
+
const books = result[BRAND_NS.book] ?? [];
|
|
4255
|
+
if (books.length > 1)
|
|
4256
|
+
throw new BrandConflictError(`multiple brand books claim channel ${channelId}`);
|
|
4257
|
+
return books[0] ?? null;
|
|
2539
4258
|
}
|
|
2540
|
-
function brandInputFor(body, attachments) {
|
|
4259
|
+
function brandInputFor(body, attachments, author, attachedAssetIds = []) {
|
|
2541
4260
|
const row = body.event.row;
|
|
2542
|
-
const
|
|
4261
|
+
const actor = author ? `${author.displayName} (${author.kind}${author.managerDisplayName ? `, managed by ${author.managerDisplayName}` : ""})` : row.authorKind === "bot" ? "a managed agent" : "a participant";
|
|
4262
|
+
const prompt = `A new message arrived in this brand book's channel from ${actor}: "${String(row.body ?? "")}". Ground yourself first (read_brand_book, list_assets), then move the work forward. Study real material, justify colors, and park exact proposals. A human approves in the Brand surface; acceptance applies the reviewed facet and recompiles dependent tokens. ` + (attachedAssetIds.length > 0 ? `This message atomically attached Brand asset id${attachedAssetIds.length === 1 ? "" : "s"} ${attachedAssetIds.join(", ")}; use view_asset on those exact ids before drawing conclusions. ` : "") + "Keep any chat reply concise.";
|
|
2543
4263
|
return attachments?.length ? [...attachments, { type: "text", text: prompt }] : prompt;
|
|
2544
4264
|
}
|
|
2545
|
-
var PRETURN_IMAGE_TYPES = /* @__PURE__ */ new Set([
|
|
2546
|
-
"image/png",
|
|
2547
|
-
"image/jpeg",
|
|
2548
|
-
"image/gif",
|
|
2549
|
-
"image/webp"
|
|
2550
|
-
]);
|
|
2551
|
-
async function assetBlocksFor(db, fileBaseUrl, raw) {
|
|
2552
|
-
if (!Array.isArray(raw)) return [];
|
|
2553
|
-
const ids = raw.filter((v) => typeof v === "string");
|
|
2554
|
-
if (ids.length === 0) return [];
|
|
2555
|
-
const res = await db.query({ [BRAND_NS.asset]: { $: { where: { id: { $in: ids } } } } });
|
|
2556
|
-
const byId = new Map((res[BRAND_NS.asset] ?? []).map((a2) => [a2.id, a2]));
|
|
2557
|
-
const blocks = [];
|
|
2558
|
-
for (const id of ids) {
|
|
2559
|
-
const asset = byId.get(id);
|
|
2560
|
-
if (!asset || asset.deletedAt != null || !PRETURN_IMAGE_TYPES.has(asset.contentType)) continue;
|
|
2561
|
-
const url = asset.url.startsWith("http") ? asset.url : fileBaseUrl + asset.url;
|
|
2562
|
-
blocks.push({ type: "image", source: { type: "url", url } });
|
|
2563
|
-
}
|
|
2564
|
-
return blocks;
|
|
2565
|
-
}
|
|
2566
4265
|
async function dispatchBrandTurn(deps, body) {
|
|
4266
|
+
if (body.appId !== deps.appId || body.trigger.skill !== "brand" || body.event.ns !== CHAT_MESSAGE_NS) throw new BrandForbiddenError("foreign or non-brand dispatch");
|
|
2567
4267
|
const channelId = String(body.event.row.channelId ?? "");
|
|
2568
4268
|
const book = await bookForChannel(deps.db, channelId);
|
|
2569
|
-
if (!book)
|
|
2570
|
-
|
|
4269
|
+
if (!book)
|
|
4270
|
+
throw new BrandNotFoundError(
|
|
4271
|
+
`brand book for channel ${channelId || "(missing channelId)"}`
|
|
4272
|
+
);
|
|
4273
|
+
const agentId = body.trigger.runAs.agentId;
|
|
4274
|
+
if (!book.memberIds.includes(agentId))
|
|
4275
|
+
throw new BrandNotFoundError(`brand book for channel ${channelId}`);
|
|
4276
|
+
const read = await deps.authorizeCapability({
|
|
4277
|
+
agentId,
|
|
4278
|
+
bookId: book.id,
|
|
4279
|
+
capability: "brand.read"
|
|
4280
|
+
});
|
|
4281
|
+
if (!read || read.capability !== "brand.read" || !read.authorityRef)
|
|
4282
|
+
throw new BrandForbiddenError("agent lacks live brand.read");
|
|
4283
|
+
const authorId = typeof body.event.row.authorId === "string" ? body.event.row.authorId : "";
|
|
4284
|
+
const authors = authorId ? await deps.resolvePrincipals({
|
|
4285
|
+
requesterAgentId: agentId,
|
|
4286
|
+
bookId: book.id,
|
|
4287
|
+
principalIds: [authorId]
|
|
4288
|
+
}) : [];
|
|
4289
|
+
const author = authors.find((candidate) => candidate.id === authorId);
|
|
4290
|
+
const attachedAssetIds = await attachedBrandAssetIds(
|
|
4291
|
+
deps.db,
|
|
4292
|
+
book,
|
|
4293
|
+
body.event.row
|
|
4294
|
+
);
|
|
4295
|
+
const runtime = await deps.agentRuntimeFor({
|
|
4296
|
+
appId: body.appId,
|
|
4297
|
+
agentId,
|
|
4298
|
+
bookId: book.id,
|
|
4299
|
+
triggerId: body.trigger.id,
|
|
4300
|
+
eventId: body.event.id,
|
|
4301
|
+
jobId: body.jobId
|
|
4302
|
+
});
|
|
4303
|
+
if (!runtime.credentialRef || !runtime.jobId || runtime.jobId !== body.jobId)
|
|
4304
|
+
throw new BrandForbiddenError("agent data credential is not bound");
|
|
4305
|
+
let durableOutcome = false;
|
|
4306
|
+
const bridge = {
|
|
4307
|
+
...runtime.bridge,
|
|
4308
|
+
createProposal: async (input) => {
|
|
4309
|
+
const proposal = await runtime.bridge.createProposal(input);
|
|
4310
|
+
durableOutcome = true;
|
|
4311
|
+
return proposal;
|
|
4312
|
+
},
|
|
4313
|
+
recordAssetAnalysis: async (input) => {
|
|
4314
|
+
const asset = await runtime.bridge.recordAssetAnalysis(input);
|
|
4315
|
+
durableOutcome = true;
|
|
4316
|
+
return asset;
|
|
4317
|
+
}
|
|
4318
|
+
};
|
|
4319
|
+
const visionInToolResults = supportsBrandVision(
|
|
4320
|
+
deps.catalog?.[deps.model]
|
|
4321
|
+
);
|
|
2571
4322
|
const self = {
|
|
2572
|
-
selfId:
|
|
4323
|
+
selfId: agentId,
|
|
2573
4324
|
kind: "bot",
|
|
2574
4325
|
displayName: body.trigger.runAs.persona
|
|
2575
4326
|
};
|
|
2576
4327
|
const persona = createBrandPersona({
|
|
2577
4328
|
model: deps.model,
|
|
2578
|
-
brand: {
|
|
4329
|
+
brand: {
|
|
4330
|
+
db: runtime.db,
|
|
4331
|
+
bookId: book.id,
|
|
4332
|
+
self,
|
|
4333
|
+
agentDbBinding: {
|
|
4334
|
+
principalId: agentId,
|
|
4335
|
+
credentialRef: runtime.credentialRef
|
|
4336
|
+
},
|
|
4337
|
+
agentJobId: runtime.jobId,
|
|
4338
|
+
agentBridge: bridge,
|
|
4339
|
+
authorizeCapability: deps.authorizeCapability,
|
|
4340
|
+
resolvePrincipals: deps.resolvePrincipals,
|
|
4341
|
+
visionInToolResults,
|
|
4342
|
+
...deps.newId ? { newId: deps.newId } : {}
|
|
4343
|
+
},
|
|
2579
4344
|
skills: deps.chatSkillFor ? [deps.chatSkillFor(channelId, self)] : []
|
|
2580
4345
|
});
|
|
2581
|
-
const
|
|
2582
|
-
|
|
2583
|
-
|
|
4346
|
+
const run = await deps.runAgent(deps.inference, persona, {
|
|
4347
|
+
// Models without tool-result vision receive no raw/signed fallback URL.
|
|
4348
|
+
input: brandInputFor(body, void 0, author, attachedAssetIds)
|
|
4349
|
+
});
|
|
4350
|
+
return { finalText: run.finalText, durableOutcome };
|
|
2584
4351
|
}
|
|
2585
4352
|
|
|
2586
4353
|
// src/descriptor.ts
|
|
@@ -2608,10 +4375,12 @@ var brandIntegration = {
|
|
|
2608
4375
|
schema: BRAND_SCHEMA,
|
|
2609
4376
|
rules: brandRules(),
|
|
2610
4377
|
triggers: [],
|
|
4378
|
+
agentProfile: BRAND_AGENT_PROFILE,
|
|
2611
4379
|
provision: {
|
|
2612
4380
|
human: [
|
|
4381
|
+
"Configure direct Clerk human approval through authorizeCapability + consumeHumanExact; delegated, machine, and agent principals must not resolve proposals.",
|
|
2613
4382
|
'Choose the trigger mode: one global mention-gated bot (brandBotTrigger({ mention: "@brand" }), no channels) or per-book channel-scoped bots (channels: [book.channelId], no mention).',
|
|
2614
|
-
"Pick
|
|
4383
|
+
"Pick a model where supportsBrandVision(catalog[model]) is true for in-conversation asset viewing; secure dispatch never falls back to signed or persistent asset URLs.",
|
|
2615
4384
|
"Decide whether compiled tokens are public (publicTokens serves tokens.css/tokens.json unauthenticated)."
|
|
2616
4385
|
],
|
|
2617
4386
|
cli: [
|
|
@@ -2619,10 +4388,14 @@ var brandIntegration = {
|
|
|
2619
4388
|
"Install brandRules() at /app/:id/admin/rules (merged with the app's existing rules).",
|
|
2620
4389
|
"Register a brandBotTrigger(...) per bot at /app/:id/admin/triggers \u2014 global mention-gated, or channel-scoped per book.",
|
|
2621
4390
|
"Seed the bot's agent id into each brand_book.memberIds roster (audienceFanoutOps covers existing child rows).",
|
|
2622
|
-
"
|
|
4391
|
+
"Issue the bot brand.read + brand.edit and expose only BRAND_AGENT_PROFILE semantic operations; deny raw brand_* writes and raw file operations.",
|
|
4392
|
+
"Provision private file signing and verifySourceAssetSnapshot; never expose an admin credential or persistent asset URL to the agent."
|
|
2623
4393
|
],
|
|
2624
4394
|
doctor: [
|
|
2625
|
-
"All
|
|
4395
|
+
"All six brand_* namespaces have rules installed; raw browser/agent writes are denied and child reads use current linked-book membership.",
|
|
4396
|
+
"The agent runtime matches BRAND_AGENT_PROFILE: brand.read/edit only, semantic operations only, no raw brand_* or file access.",
|
|
4397
|
+
"Proposal decisions consume a direct-human exact brand.approve authority use and persist one guarded brand_approval_receipt.",
|
|
4398
|
+
"Private source assets are signed briefly and verifySourceAssetSnapshot revalidates path, ETag, size, content type, and digest before approval.",
|
|
2626
4399
|
"brand_book.id/slug, brand_section.key, and the other mirrored id attrs are unique.",
|
|
2627
4400
|
"Each registered trigger's agent id is present in its target books' memberIds rosters.",
|
|
2628
4401
|
"Books with channelId set point at real chat channels when @odla-ai/chat is installed."
|