@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.js
CHANGED
|
@@ -59,6 +59,7 @@ var BRAND_NS = {
|
|
|
59
59
|
section: "brand_section",
|
|
60
60
|
palette: "brand_palette",
|
|
61
61
|
proposal: "brand_proposal",
|
|
62
|
+
approvalReceipt: "brand_approval_receipt",
|
|
62
63
|
asset: "brand_asset"
|
|
63
64
|
};
|
|
64
65
|
var BOOK_STATUSES = ["draft", "active", "archived"];
|
|
@@ -66,9 +67,9 @@ var SECTION_KINDS = ["palette", "typography", "voice", "logo", "imagery"];
|
|
|
66
67
|
var SECTION_STATUSES = ["draft", "approved"];
|
|
67
68
|
var PALETTE_STATUSES = ["active", "archived"];
|
|
68
69
|
var PALETTE_SOURCES = ["extracted", "derived", "manual"];
|
|
69
|
-
var PROPOSAL_KINDS =
|
|
70
|
+
var PROPOSAL_KINDS = SECTION_KINDS;
|
|
70
71
|
var PROPOSAL_STATUSES = ["open", "accepted", "rejected", "superseded"];
|
|
71
|
-
var ASSET_KINDS = ["logo", "wordmark", "inspiration", "document", "
|
|
72
|
+
var ASSET_KINDS = ["logo", "wordmark", "inspiration", "document", "other"];
|
|
72
73
|
var SWATCH_ROLES = [
|
|
73
74
|
"primary",
|
|
74
75
|
"secondary",
|
|
@@ -84,6 +85,25 @@ var SWATCH_ROLES = [
|
|
|
84
85
|
"custom"
|
|
85
86
|
];
|
|
86
87
|
|
|
88
|
+
// src/agent-profile.ts
|
|
89
|
+
var BRAND_AGENT_PROFILE = {
|
|
90
|
+
version: 1,
|
|
91
|
+
projectCapabilities: ["brand.read", "brand.edit"],
|
|
92
|
+
semanticOperations: [
|
|
93
|
+
"brand.proposal.create",
|
|
94
|
+
"brand.asset.analysis.record",
|
|
95
|
+
"brand.asset.content.read"
|
|
96
|
+
],
|
|
97
|
+
rawBrandWrites: false,
|
|
98
|
+
rawFileOperations: false,
|
|
99
|
+
approval: {
|
|
100
|
+
principalKind: "human",
|
|
101
|
+
projectCapability: "brand.approve",
|
|
102
|
+
capability: "brand.proposal.resolve",
|
|
103
|
+
exactAction: true
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
|
|
87
107
|
// src/errors.ts
|
|
88
108
|
var BrandInputError = class extends Error {
|
|
89
109
|
fields;
|
|
@@ -99,6 +119,31 @@ var BrandNotFoundError = class extends Error {
|
|
|
99
119
|
this.name = "BrandNotFoundError";
|
|
100
120
|
}
|
|
101
121
|
};
|
|
122
|
+
var BrandGoneError = class extends Error {
|
|
123
|
+
constructor(message = "brand authority or resource is no longer available") {
|
|
124
|
+
super(message);
|
|
125
|
+
this.name = "BrandGoneError";
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
var BrandForbiddenError = class extends Error {
|
|
129
|
+
constructor(message = "forbidden") {
|
|
130
|
+
super(message);
|
|
131
|
+
this.name = "BrandForbiddenError";
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
var BrandConflictError = class extends Error {
|
|
135
|
+
constructor(message) {
|
|
136
|
+
super(message);
|
|
137
|
+
this.name = "BrandConflictError";
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
var BrandReviewStateChangedError = class extends BrandConflictError {
|
|
141
|
+
code = "brand_review_state_changed";
|
|
142
|
+
constructor(message = "proposal or live brand dependencies changed; review and approve again") {
|
|
143
|
+
super(message);
|
|
144
|
+
this.name = "BrandReviewStateChangedError";
|
|
145
|
+
}
|
|
146
|
+
};
|
|
102
147
|
|
|
103
148
|
// src/deps.ts
|
|
104
149
|
async function defaultFetchBytes(url) {
|
|
@@ -134,17 +179,21 @@ var BRAND_SCHEMA = {
|
|
|
134
179
|
[BRAND_NS.book]: {
|
|
135
180
|
attrs: {
|
|
136
181
|
id: uniq("string"),
|
|
182
|
+
version: a("number"),
|
|
137
183
|
slug: uniq("string"),
|
|
138
184
|
name: idx("string"),
|
|
139
185
|
status: idx("string"),
|
|
140
186
|
// draft | active | archived
|
|
141
187
|
ownerId: idx("string"),
|
|
188
|
+
createdAuthorityRef: idx("string"),
|
|
189
|
+
rosterAuthorityRef: opt("string"),
|
|
142
190
|
memberIds: a("json"),
|
|
143
191
|
// the auth roster, incl. the bot agent id
|
|
144
|
-
channelId:
|
|
192
|
+
channelId: a("string", { unique: true, indexed: true, optional: true }),
|
|
145
193
|
activePaletteId: opt("string"),
|
|
194
|
+
activationRevision: a("number"),
|
|
146
195
|
tokens: opt("json"),
|
|
147
|
-
//
|
|
196
|
+
// maps + complete palette/typography receipt provenance
|
|
148
197
|
summary: opt("string"),
|
|
149
198
|
createdAt: idx("date"),
|
|
150
199
|
updatedAt: idx("date")
|
|
@@ -162,7 +211,9 @@ var BRAND_SCHEMA = {
|
|
|
162
211
|
content: a("json"),
|
|
163
212
|
audience: a("json"),
|
|
164
213
|
updatedBy: a("string"),
|
|
165
|
-
updatedAt: idx("date")
|
|
214
|
+
updatedAt: idx("date"),
|
|
215
|
+
approvalReceiptId: opt("string"),
|
|
216
|
+
approvalActionDigest: opt("string")
|
|
166
217
|
}
|
|
167
218
|
},
|
|
168
219
|
[BRAND_NS.palette]: {
|
|
@@ -178,7 +229,9 @@ var BRAND_SCHEMA = {
|
|
|
178
229
|
source: a("string"),
|
|
179
230
|
// extracted | derived | manual
|
|
180
231
|
rationale: opt("string"),
|
|
181
|
-
proposalId:
|
|
232
|
+
proposalId: idx("string"),
|
|
233
|
+
approvalReceiptId: a("string"),
|
|
234
|
+
approvalActionDigest: a("string"),
|
|
182
235
|
audience: a("json"),
|
|
183
236
|
createdAt: idx("date"),
|
|
184
237
|
updatedAt: idx("date")
|
|
@@ -194,14 +247,45 @@ var BRAND_SCHEMA = {
|
|
|
194
247
|
// open | accepted | rejected | superseded
|
|
195
248
|
payload: a("json"),
|
|
196
249
|
rationale: a("string"),
|
|
197
|
-
|
|
198
|
-
|
|
250
|
+
provenance: a("json"),
|
|
251
|
+
// exact source asset/message/turn ids (nullable asset)
|
|
252
|
+
reviewDigest: idx("string"),
|
|
199
253
|
audience: a("json"),
|
|
200
254
|
createdBy: a("string"),
|
|
255
|
+
createdAuthorityRef: a("string"),
|
|
201
256
|
createdAt: idx("date"),
|
|
202
257
|
resolvedBy: opt("string"),
|
|
203
258
|
resolvedAt: opt("date"),
|
|
204
|
-
resolutionNote: opt("string")
|
|
259
|
+
resolutionNote: opt("string"),
|
|
260
|
+
approvedBy: opt("string"),
|
|
261
|
+
appliedBy: opt("string"),
|
|
262
|
+
resolutionReceiptId: opt("string"),
|
|
263
|
+
resolutionActionDigest: opt("string")
|
|
264
|
+
}
|
|
265
|
+
},
|
|
266
|
+
[BRAND_NS.approvalReceipt]: {
|
|
267
|
+
attrs: {
|
|
268
|
+
id: uniq("string"),
|
|
269
|
+
version: a("number"),
|
|
270
|
+
mutationKey: uniq("string"),
|
|
271
|
+
bookId: idx("string"),
|
|
272
|
+
proposalId: idx("string"),
|
|
273
|
+
resolution: idx("string"),
|
|
274
|
+
// accepted | rejected
|
|
275
|
+
paletteId: opt("string"),
|
|
276
|
+
resolutionNote: opt("string"),
|
|
277
|
+
reviewedProposal: a("json"),
|
|
278
|
+
actionDigest: idx("string"),
|
|
279
|
+
decisionBinding: a("json"),
|
|
280
|
+
approvedBy: idx("string"),
|
|
281
|
+
approvedByKind: a("string"),
|
|
282
|
+
appliedBy: idx("string"),
|
|
283
|
+
appliedByKind: a("string"),
|
|
284
|
+
authorityRef: idx("string"),
|
|
285
|
+
authorityCapability: a("string"),
|
|
286
|
+
authorityConsumption: a("json"),
|
|
287
|
+
createdAt: idx("date"),
|
|
288
|
+
receiptDigest: idx("string")
|
|
205
289
|
}
|
|
206
290
|
},
|
|
207
291
|
[BRAND_NS.asset]: {
|
|
@@ -209,59 +293,94 @@ var BRAND_SCHEMA = {
|
|
|
209
293
|
id: uniq("string"),
|
|
210
294
|
bookId: idx("string"),
|
|
211
295
|
kind: idx("string"),
|
|
212
|
-
// logo | wordmark | inspiration | document |
|
|
296
|
+
// logo | wordmark | inspiration | document | other
|
|
213
297
|
path: idx("string"),
|
|
214
|
-
|
|
298
|
+
storageObjectId: idx("string"),
|
|
299
|
+
contentDigest: idx("string"),
|
|
215
300
|
contentType: a("string"),
|
|
216
301
|
size: a("number"),
|
|
302
|
+
status: idx("string"),
|
|
303
|
+
// live | deleting | deleted
|
|
217
304
|
title: opt("string"),
|
|
218
305
|
analysis: opt("json"),
|
|
219
306
|
// { description, dominantColors: hex[], tags }
|
|
220
307
|
analyzedAt: opt("date"),
|
|
308
|
+
analysisRevision: a("number"),
|
|
309
|
+
analysisDigest: opt("string"),
|
|
310
|
+
analyzedBy: opt("string"),
|
|
311
|
+
analyzedAuthorityRef: opt("string"),
|
|
221
312
|
audience: a("json"),
|
|
222
313
|
uploadedBy: a("string"),
|
|
314
|
+
uploadedAuthorityRef: a("string"),
|
|
223
315
|
createdAt: idx("date"),
|
|
224
|
-
deletedAt: opt("date")
|
|
316
|
+
deletedAt: opt("date"),
|
|
225
317
|
// tombstone — asset rows are never row-deleted
|
|
318
|
+
deletedBy: opt("string"),
|
|
319
|
+
deletedAuthorityRef: opt("string")
|
|
226
320
|
}
|
|
227
321
|
}
|
|
228
322
|
},
|
|
229
|
-
links: {
|
|
323
|
+
links: {
|
|
324
|
+
brandSectionBook: {
|
|
325
|
+
forward: { on: BRAND_NS.section, has: "one", label: "book" },
|
|
326
|
+
reverse: { on: BRAND_NS.book, has: "many", label: "sections" }
|
|
327
|
+
},
|
|
328
|
+
brandPaletteBook: {
|
|
329
|
+
forward: { on: BRAND_NS.palette, has: "one", label: "book" },
|
|
330
|
+
reverse: { on: BRAND_NS.book, has: "many", label: "palettes" }
|
|
331
|
+
},
|
|
332
|
+
brandProposalBook: {
|
|
333
|
+
forward: { on: BRAND_NS.proposal, has: "one", label: "book" },
|
|
334
|
+
reverse: { on: BRAND_NS.book, has: "many", label: "proposals" }
|
|
335
|
+
},
|
|
336
|
+
brandApprovalReceiptBook: {
|
|
337
|
+
forward: { on: BRAND_NS.approvalReceipt, has: "one", label: "book" },
|
|
338
|
+
reverse: { on: BRAND_NS.book, has: "many", label: "approvalReceipts" }
|
|
339
|
+
},
|
|
340
|
+
brandAssetBook: {
|
|
341
|
+
forward: { on: BRAND_NS.asset, has: "one", label: "book" },
|
|
342
|
+
reverse: { on: BRAND_NS.book, has: "many", label: "assets" }
|
|
343
|
+
}
|
|
344
|
+
}
|
|
230
345
|
};
|
|
231
346
|
|
|
232
347
|
// src/rules.ts
|
|
233
|
-
var
|
|
348
|
+
var CURRENT_BOOK_MEMBER = "ref('book.memberIds').exists(members, auth.id in members)";
|
|
234
349
|
var BRAND_RULES = {
|
|
235
350
|
[BRAND_NS.book]: {
|
|
236
351
|
view: "auth.id in data.memberIds",
|
|
237
|
-
//
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
352
|
+
// Every mutation is worker-routed: CEL cannot distinguish an update from
|
|
353
|
+
// a retract, so effect-bearing fields must never be browser-writable.
|
|
354
|
+
create: "false",
|
|
355
|
+
update: "false",
|
|
356
|
+
delete: "false"
|
|
241
357
|
},
|
|
242
358
|
[BRAND_NS.section]: {
|
|
243
|
-
view:
|
|
244
|
-
create:
|
|
245
|
-
update:
|
|
359
|
+
view: CURRENT_BOOK_MEMBER,
|
|
360
|
+
create: "false",
|
|
361
|
+
update: "false",
|
|
246
362
|
delete: "false"
|
|
247
|
-
// sections are upserted in place, never removed
|
|
248
363
|
},
|
|
249
364
|
[BRAND_NS.palette]: {
|
|
250
|
-
view:
|
|
251
|
-
create:
|
|
252
|
-
update:
|
|
365
|
+
view: CURRENT_BOOK_MEMBER,
|
|
366
|
+
create: "false",
|
|
367
|
+
update: "false",
|
|
253
368
|
delete: "false"
|
|
254
|
-
// archive via status, keep provenance
|
|
255
369
|
},
|
|
256
370
|
[BRAND_NS.proposal]: {
|
|
257
|
-
view:
|
|
258
|
-
create:
|
|
259
|
-
update:
|
|
371
|
+
view: CURRENT_BOOK_MEMBER,
|
|
372
|
+
create: "false",
|
|
373
|
+
update: "false",
|
|
374
|
+
delete: "false"
|
|
375
|
+
},
|
|
376
|
+
[BRAND_NS.approvalReceipt]: {
|
|
377
|
+
view: CURRENT_BOOK_MEMBER,
|
|
378
|
+
create: "false",
|
|
379
|
+
update: "false",
|
|
260
380
|
delete: "false"
|
|
261
|
-
// resolution history is the audit trail
|
|
262
381
|
},
|
|
263
382
|
[BRAND_NS.asset]: {
|
|
264
|
-
view:
|
|
383
|
+
view: CURRENT_BOOK_MEMBER,
|
|
265
384
|
create: "false",
|
|
266
385
|
update: "false",
|
|
267
386
|
delete: "false"
|
|
@@ -278,7 +397,6 @@ var ASSET_CONTENT_TYPES = /* @__PURE__ */ new Set([
|
|
|
278
397
|
"image/jpeg",
|
|
279
398
|
"image/gif",
|
|
280
399
|
"image/webp",
|
|
281
|
-
"image/svg+xml",
|
|
282
400
|
"application/pdf"
|
|
283
401
|
]);
|
|
284
402
|
var HEX_RGB = /^#[0-9a-f]{3}$/;
|
|
@@ -395,8 +513,11 @@ function assertSectionContent(kind, content) {
|
|
|
395
513
|
}
|
|
396
514
|
function safeFileName(name) {
|
|
397
515
|
if (typeof name !== "string") throw new BrandInputError("file name must be a string");
|
|
398
|
-
|
|
399
|
-
|
|
516
|
+
if (/[/\\?#%\u0000-\u001f\u007f]/.test(name))
|
|
517
|
+
throw new BrandInputError("file name must not contain path or URL delimiter characters");
|
|
518
|
+
const cleaned = name.trim().replace(/^\.+/, "");
|
|
519
|
+
if (cleaned === "" || cleaned === "." || cleaned === "..")
|
|
520
|
+
throw new BrandInputError("file name is empty or a dot segment after sanitizing");
|
|
400
521
|
return cleaned.slice(0, 120);
|
|
401
522
|
}
|
|
402
523
|
function assertAssetContentType(value) {
|
|
@@ -421,6 +542,237 @@ function assertAnalysis(value) {
|
|
|
421
542
|
};
|
|
422
543
|
}
|
|
423
544
|
|
|
545
|
+
// src/review-json.ts
|
|
546
|
+
var record = (value) => typeof value === "object" && value !== null && !Array.isArray(value) && (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null);
|
|
547
|
+
function isBoundedBrandJson(root, limits = {}) {
|
|
548
|
+
const maxDepth = limits.maxDepth ?? 12;
|
|
549
|
+
const maxNodes = limits.maxNodes ?? 2048;
|
|
550
|
+
const maxBytes = limits.maxBytes ?? 32 * 1024;
|
|
551
|
+
const stack = [{
|
|
552
|
+
value: root,
|
|
553
|
+
depth: 0
|
|
554
|
+
}];
|
|
555
|
+
const seen = /* @__PURE__ */ new Set();
|
|
556
|
+
let nodes = 0;
|
|
557
|
+
while (stack.length) {
|
|
558
|
+
const { value, depth } = stack.pop();
|
|
559
|
+
if (++nodes > maxNodes || depth > maxDepth) return false;
|
|
560
|
+
if (value === null || typeof value === "string" || typeof value === "boolean") continue;
|
|
561
|
+
if (typeof value === "number") {
|
|
562
|
+
if (!Number.isFinite(value)) return false;
|
|
563
|
+
continue;
|
|
564
|
+
}
|
|
565
|
+
if (typeof value !== "object" || seen.has(value)) return false;
|
|
566
|
+
seen.add(value);
|
|
567
|
+
if (Array.isArray(value)) {
|
|
568
|
+
for (const child of value)
|
|
569
|
+
stack.push({ value: child, depth: depth + 1 });
|
|
570
|
+
} else if (record(value)) {
|
|
571
|
+
for (const child of Object.values(value))
|
|
572
|
+
stack.push({ value: child, depth: depth + 1 });
|
|
573
|
+
} else {
|
|
574
|
+
return false;
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
try {
|
|
578
|
+
return new TextEncoder().encode(JSON.stringify(root)).byteLength <= maxBytes;
|
|
579
|
+
} catch {
|
|
580
|
+
return false;
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
function canonical(value) {
|
|
584
|
+
if (value === null || typeof value !== "object") return JSON.stringify(value);
|
|
585
|
+
if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
|
|
586
|
+
const row = value;
|
|
587
|
+
return `{${Object.keys(row).sort().map((key) => `${JSON.stringify(key)}:${canonical(row[key])}`).join(",")}}`;
|
|
588
|
+
}
|
|
589
|
+
function canonicalBrandJson(value) {
|
|
590
|
+
if (!isBoundedBrandJson(value, {
|
|
591
|
+
maxDepth: 20,
|
|
592
|
+
maxNodes: 8192,
|
|
593
|
+
maxBytes: 128 * 1024
|
|
594
|
+
})) throw new TypeError("brand digest input must be bounded finite JSON");
|
|
595
|
+
return canonical(value);
|
|
596
|
+
}
|
|
597
|
+
async function brandJsonDigest(value) {
|
|
598
|
+
const bytes = await crypto.subtle.digest(
|
|
599
|
+
"SHA-256",
|
|
600
|
+
new TextEncoder().encode(canonicalBrandJson(value))
|
|
601
|
+
);
|
|
602
|
+
return `sha256:${[...new Uint8Array(bytes)].map((byte) => byte.toString(16).padStart(2, "0")).join("")}`;
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
// src/review.ts
|
|
606
|
+
var DIGEST = /^sha256:[0-9a-f]{64}$/;
|
|
607
|
+
var record2 = (value) => typeof value === "object" && value !== null && !Array.isArray(value) && (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null);
|
|
608
|
+
var exactKeys = (value, required, optional = []) => {
|
|
609
|
+
const allowed = /* @__PURE__ */ new Set([...required, ...optional]);
|
|
610
|
+
return required.every((key) => Object.hasOwn(value, key)) && Object.keys(value).every((key) => allowed.has(key));
|
|
611
|
+
};
|
|
612
|
+
var boundedString = (value, max = 200) => typeof value === "string" && value.length > 0 && value.length <= max;
|
|
613
|
+
var safeTime = (value) => Number.isSafeInteger(value) && value >= 0;
|
|
614
|
+
function brandAuthorityRef(consumption) {
|
|
615
|
+
return `authority:${consumption.grantId}:v${consumption.grantVersion}`;
|
|
616
|
+
}
|
|
617
|
+
var SNAPSHOT_REQUIRED = [
|
|
618
|
+
"id",
|
|
619
|
+
"bookId",
|
|
620
|
+
"kind",
|
|
621
|
+
"status",
|
|
622
|
+
"payload",
|
|
623
|
+
"rationale",
|
|
624
|
+
"provenance",
|
|
625
|
+
"reviewDigest",
|
|
626
|
+
"audience",
|
|
627
|
+
"createdBy",
|
|
628
|
+
"createdAuthorityRef",
|
|
629
|
+
"createdAt"
|
|
630
|
+
];
|
|
631
|
+
function validSnapshotShape(value) {
|
|
632
|
+
if (!record2(value) || !exactKeys(value, SNAPSHOT_REQUIRED)) return false;
|
|
633
|
+
const provenance = value.provenance;
|
|
634
|
+
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, [
|
|
635
|
+
"sourceAssetId",
|
|
636
|
+
"sourceAsset",
|
|
637
|
+
"messageId",
|
|
638
|
+
"turnId",
|
|
639
|
+
"taintLabels"
|
|
640
|
+
]) && (provenance.sourceAssetId === null || boundedString(provenance.sourceAssetId)) && (provenance.sourceAsset === null || record2(provenance.sourceAsset) && exactKeys(provenance.sourceAsset, [
|
|
641
|
+
"assetId",
|
|
642
|
+
"contentDigest",
|
|
643
|
+
"objectEtag",
|
|
644
|
+
"objectSize",
|
|
645
|
+
"pathDigest",
|
|
646
|
+
"contentType",
|
|
647
|
+
"analysisRevision",
|
|
648
|
+
"analysisDigest"
|
|
649
|
+
]) && 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);
|
|
650
|
+
}
|
|
651
|
+
var CONSUMPTION_REQUIRED = [
|
|
652
|
+
"id",
|
|
653
|
+
"grantId",
|
|
654
|
+
"grantVersion",
|
|
655
|
+
"useNumber",
|
|
656
|
+
"actorPrincipalId",
|
|
657
|
+
"actorKind",
|
|
658
|
+
"credentialId",
|
|
659
|
+
"credentialKind",
|
|
660
|
+
"appId",
|
|
661
|
+
"appIncarnation",
|
|
662
|
+
"capability",
|
|
663
|
+
"projectCapability",
|
|
664
|
+
"effect",
|
|
665
|
+
"actionDigest",
|
|
666
|
+
"resourceDigest",
|
|
667
|
+
"constraintEvidence",
|
|
668
|
+
"consumptionIdempotencyKey",
|
|
669
|
+
"requestDigest",
|
|
670
|
+
"consumedAt"
|
|
671
|
+
];
|
|
672
|
+
function isBrandHumanAuthorityConsumption(value) {
|
|
673
|
+
if (!record2(value) || !exactKeys(value, CONSUMPTION_REQUIRED)) return false;
|
|
674
|
+
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);
|
|
675
|
+
}
|
|
676
|
+
var RECEIPT_REQUIRED = [
|
|
677
|
+
"version",
|
|
678
|
+
"id",
|
|
679
|
+
"mutationKey",
|
|
680
|
+
"bookId",
|
|
681
|
+
"proposalId",
|
|
682
|
+
"resolution",
|
|
683
|
+
"reviewedProposal",
|
|
684
|
+
"actionDigest",
|
|
685
|
+
"decisionBinding",
|
|
686
|
+
"approvedBy",
|
|
687
|
+
"approvedByKind",
|
|
688
|
+
"appliedBy",
|
|
689
|
+
"appliedByKind",
|
|
690
|
+
"authorityRef",
|
|
691
|
+
"authorityCapability",
|
|
692
|
+
"authorityConsumption",
|
|
693
|
+
"createdAt",
|
|
694
|
+
"receiptDigest"
|
|
695
|
+
];
|
|
696
|
+
async function verifyBrandApprovalReceipt(receipt2) {
|
|
697
|
+
try {
|
|
698
|
+
if (!isBoundedBrandJson(receipt2, { maxDepth: 14, maxNodes: 2500, maxBytes: 48 * 1024 }))
|
|
699
|
+
return false;
|
|
700
|
+
if (!record2(receipt2) || !exactKeys(receipt2, RECEIPT_REQUIRED, ["paletteId", "resolutionNote"]))
|
|
701
|
+
return false;
|
|
702
|
+
const binding = receipt2.decisionBinding;
|
|
703
|
+
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, [
|
|
704
|
+
"version",
|
|
705
|
+
"bookVersion",
|
|
706
|
+
"activationRevision",
|
|
707
|
+
"activePaletteId",
|
|
708
|
+
"memberIdsDigest",
|
|
709
|
+
"activePaletteDigest",
|
|
710
|
+
"typographyDigest",
|
|
711
|
+
"sourceAssetDigest",
|
|
712
|
+
"effectDigest"
|
|
713
|
+
]) || 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;
|
|
714
|
+
const reviewed = receipt2.reviewedProposal;
|
|
715
|
+
const authority = receipt2.authorityConsumption;
|
|
716
|
+
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;
|
|
717
|
+
const { reviewDigest, ...unsignedReview } = reviewed;
|
|
718
|
+
if (await brandJsonDigest(unsignedReview) !== reviewDigest) return false;
|
|
719
|
+
const expectedAction = await brandJsonDigest({
|
|
720
|
+
version: 1,
|
|
721
|
+
reviewedProposal: reviewed,
|
|
722
|
+
resolution: receipt2.resolution,
|
|
723
|
+
resolutionNote: receipt2.resolutionNote ?? null,
|
|
724
|
+
paletteId: receipt2.paletteId ?? null,
|
|
725
|
+
decisionBinding: binding
|
|
726
|
+
});
|
|
727
|
+
if (expectedAction !== receipt2.actionDigest) return false;
|
|
728
|
+
if (await brandJsonDigest({ bookId: receipt2.bookId, proposalId: receipt2.proposalId }) !== authority.resourceDigest) return false;
|
|
729
|
+
const { receiptDigest, ...immutable } = receipt2;
|
|
730
|
+
return await brandJsonDigest(immutable) === receiptDigest;
|
|
731
|
+
} catch {
|
|
732
|
+
return false;
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
// src/discussion-reference.ts
|
|
737
|
+
var BRAND_DISCUSSION_REFERENCE_KINDS = [
|
|
738
|
+
"brand:book",
|
|
739
|
+
"brand:asset",
|
|
740
|
+
"brand:palette",
|
|
741
|
+
"brand:proposal",
|
|
742
|
+
"brand:receipt"
|
|
743
|
+
];
|
|
744
|
+
var SEGMENT = /^[A-Za-z0-9][A-Za-z0-9._~-]{0,199}$/;
|
|
745
|
+
function brandDiscussionReferenceId(target) {
|
|
746
|
+
return target.resourceId ? `${target.bookId}/${target.resourceId}` : target.bookId;
|
|
747
|
+
}
|
|
748
|
+
function formatBrandDiscussionReference(target) {
|
|
749
|
+
return `${target.kind}/${brandDiscussionReferenceId(target)}`;
|
|
750
|
+
}
|
|
751
|
+
function parseBrandDiscussionReference(input) {
|
|
752
|
+
let raw;
|
|
753
|
+
try {
|
|
754
|
+
raw = input instanceof URL ? input.searchParams.get("odla-ref") : new URL(input, "https://brand.invalid").searchParams.get("odla-ref");
|
|
755
|
+
} catch {
|
|
756
|
+
return null;
|
|
757
|
+
}
|
|
758
|
+
if (!raw) return null;
|
|
759
|
+
const [kind, bookId, resourceId, extra] = raw.split("/");
|
|
760
|
+
if (extra !== void 0 || !BRAND_DISCUSSION_REFERENCE_KINDS.includes(
|
|
761
|
+
kind
|
|
762
|
+
) || !bookId || !SEGMENT.test(bookId)) return null;
|
|
763
|
+
const typedKind = kind;
|
|
764
|
+
if (typedKind === "brand:book") {
|
|
765
|
+
return resourceId === void 0 ? { kind: typedKind, bookId } : null;
|
|
766
|
+
}
|
|
767
|
+
return resourceId && SEGMENT.test(resourceId) ? { kind: typedKind, bookId, resourceId } : null;
|
|
768
|
+
}
|
|
769
|
+
function brandDiscussionReferenceHref(current, target, basePath = "/") {
|
|
770
|
+
const next = new URL(current.origin);
|
|
771
|
+
next.pathname = basePath.startsWith("/") ? basePath : `/${basePath}`;
|
|
772
|
+
next.searchParams.set("odla-ref", formatBrandDiscussionReference(target));
|
|
773
|
+
return next.toString();
|
|
774
|
+
}
|
|
775
|
+
|
|
424
776
|
// src/ops/books.ts
|
|
425
777
|
var SLUG_RE = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/;
|
|
426
778
|
function createBookOps(input) {
|
|
@@ -428,7 +780,11 @@ function createBookOps(input) {
|
|
|
428
780
|
if (!SLUG_RE.test(slug))
|
|
429
781
|
throw new BrandInputError("slug must be lowercase letters, digits, and inner hyphens");
|
|
430
782
|
const name = capString(input.name, "name", 120);
|
|
431
|
-
const
|
|
783
|
+
const ownerId = capString(input.ownerId, "ownerId", 160);
|
|
784
|
+
const createdAuthorityRef = capString(input.createdAuthorityRef, "createdAuthorityRef", 200);
|
|
785
|
+
const memberIds = Array.from(/* @__PURE__ */ new Set([ownerId, ...input.memberIds.map((id, index) => capString(id, `memberIds[${index}]`, 160))]));
|
|
786
|
+
if (memberIds.length > 100)
|
|
787
|
+
throw new BrandInputError("a brand book supports at most 100 members");
|
|
432
788
|
return [
|
|
433
789
|
{
|
|
434
790
|
t: "update",
|
|
@@ -436,11 +792,14 @@ function createBookOps(input) {
|
|
|
436
792
|
id: input.id,
|
|
437
793
|
attrs: {
|
|
438
794
|
id: input.id,
|
|
795
|
+
version: 1,
|
|
439
796
|
slug,
|
|
440
797
|
name,
|
|
441
798
|
status: "draft",
|
|
442
|
-
ownerId
|
|
799
|
+
ownerId,
|
|
800
|
+
createdAuthorityRef,
|
|
443
801
|
memberIds,
|
|
802
|
+
activationRevision: 0,
|
|
444
803
|
createdAt: input.now,
|
|
445
804
|
updatedAt: input.now,
|
|
446
805
|
...input.channelId ? { channelId: input.channelId } : {}
|
|
@@ -448,8 +807,8 @@ function createBookOps(input) {
|
|
|
448
807
|
}
|
|
449
808
|
];
|
|
450
809
|
}
|
|
451
|
-
var PATCHABLE = /* @__PURE__ */ new Set(["name", "status", "channelId", "summary"
|
|
452
|
-
var CLEARABLE = /* @__PURE__ */ new Set(["channelId", "summary"
|
|
810
|
+
var PATCHABLE = /* @__PURE__ */ new Set(["name", "status", "channelId", "summary"]);
|
|
811
|
+
var CLEARABLE = /* @__PURE__ */ new Set(["channelId", "summary"]);
|
|
453
812
|
function validateBookField(key, value) {
|
|
454
813
|
switch (key) {
|
|
455
814
|
case "name":
|
|
@@ -462,13 +821,8 @@ function validateBookField(key, value) {
|
|
|
462
821
|
return capString(value, "channelId", 128);
|
|
463
822
|
case "summary":
|
|
464
823
|
return capString(value, "summary", 2e3);
|
|
465
|
-
|
|
466
|
-
return capString(value, "activePaletteId", 128);
|
|
467
|
-
default: {
|
|
468
|
-
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
469
|
-
throw new BrandInputError("tokens must be an object");
|
|
824
|
+
default:
|
|
470
825
|
return value;
|
|
471
|
-
}
|
|
472
826
|
}
|
|
473
827
|
}
|
|
474
828
|
function updateBookOps(bookId, patch, now) {
|
|
@@ -515,6 +869,7 @@ function upsertSectionOps(input) {
|
|
|
515
869
|
if (!SECTION_STATUSES.includes(status))
|
|
516
870
|
throw new BrandInputError(`status must be one of: ${SECTION_STATUSES.join(", ")}`);
|
|
517
871
|
const content = assertSectionContent(input.kind, input.content);
|
|
872
|
+
if (status === "approved" && (!input.approvalReceiptId || !input.approvalActionDigest)) throw new BrandInputError("approved sections require approval receipt provenance");
|
|
518
873
|
const key = sectionKey(input.bookId, input.kind);
|
|
519
874
|
return [
|
|
520
875
|
{
|
|
@@ -529,47 +884,105 @@ function upsertSectionOps(input) {
|
|
|
529
884
|
content,
|
|
530
885
|
audience: input.audience,
|
|
531
886
|
updatedBy: input.updatedBy,
|
|
532
|
-
updatedAt: input.now
|
|
887
|
+
updatedAt: input.now,
|
|
888
|
+
...input.approvalReceiptId ? { approvalReceiptId: capString(input.approvalReceiptId, "approvalReceiptId", 200) } : {},
|
|
889
|
+
...input.approvalActionDigest ? { approvalActionDigest: capString(input.approvalActionDigest, "approvalActionDigest", 80) } : {}
|
|
533
890
|
}
|
|
891
|
+
},
|
|
892
|
+
{
|
|
893
|
+
t: "link",
|
|
894
|
+
ns: BRAND_NS.section,
|
|
895
|
+
id: { ns: BRAND_NS.section, attr: "key", value: key },
|
|
896
|
+
label: "book",
|
|
897
|
+
target: input.bookId
|
|
534
898
|
}
|
|
535
899
|
];
|
|
536
900
|
}
|
|
901
|
+
async function proposeSectionOps(input) {
|
|
902
|
+
if (input.taintLabels.length > 16)
|
|
903
|
+
throw new BrandInputError("taintLabels must have at most 16 entries");
|
|
904
|
+
const proposal = {
|
|
905
|
+
id: input.id,
|
|
906
|
+
bookId: input.bookId,
|
|
907
|
+
kind: input.kind,
|
|
908
|
+
status: "open",
|
|
909
|
+
payload: { content: assertSectionContent(input.kind, input.content) },
|
|
910
|
+
rationale: capString(input.rationale, "rationale", 2e3),
|
|
911
|
+
provenance: {
|
|
912
|
+
sourceAssetId: input.sourceAsset?.assetId ?? null,
|
|
913
|
+
sourceAsset: input.sourceAsset ?? null,
|
|
914
|
+
messageId: capString(input.messageId, "messageId", 200),
|
|
915
|
+
turnId: capString(input.turnId, "turnId", 200),
|
|
916
|
+
taintLabels: [...new Set(input.taintLabels.map((label, index) => capString(label, `taintLabels[${index}]`, 120)))].sort()
|
|
917
|
+
},
|
|
918
|
+
audience: input.audience,
|
|
919
|
+
createdBy: input.createdBy,
|
|
920
|
+
createdAuthorityRef: input.createdAuthorityRef,
|
|
921
|
+
createdAt: input.now
|
|
922
|
+
};
|
|
923
|
+
return [
|
|
924
|
+
{
|
|
925
|
+
t: "update",
|
|
926
|
+
ns: BRAND_NS.proposal,
|
|
927
|
+
id: input.id,
|
|
928
|
+
attrs: { ...proposal, reviewDigest: await brandJsonDigest(proposal) }
|
|
929
|
+
},
|
|
930
|
+
{ t: "link", ns: BRAND_NS.proposal, id: input.id, label: "book", target: input.bookId }
|
|
931
|
+
];
|
|
932
|
+
}
|
|
537
933
|
|
|
538
934
|
// src/ops/palettes.ts
|
|
539
|
-
function proposePaletteOps(input) {
|
|
935
|
+
async function proposePaletteOps(input) {
|
|
540
936
|
const name = capString(input.name, "name", 120);
|
|
541
937
|
const rationale = capString(input.rationale, "rationale", 2e3);
|
|
542
938
|
const swatches = assertSwatches(input.swatches);
|
|
543
939
|
const seedHex = input.seedHex === void 0 ? void 0 : assertHex(input.seedHex, "seedHex");
|
|
940
|
+
if (input.taintLabels.length > 16)
|
|
941
|
+
throw new BrandInputError("taintLabels must have at most 16 entries");
|
|
544
942
|
const payload = {
|
|
545
943
|
name,
|
|
546
944
|
swatches,
|
|
547
945
|
...seedHex ? { seedHex } : {},
|
|
548
946
|
...input.contrastReport !== void 0 ? { contrastReport: input.contrastReport } : {}
|
|
549
947
|
};
|
|
948
|
+
const proposal = {
|
|
949
|
+
id: input.id,
|
|
950
|
+
bookId: input.bookId,
|
|
951
|
+
kind: "palette",
|
|
952
|
+
status: "open",
|
|
953
|
+
payload,
|
|
954
|
+
rationale,
|
|
955
|
+
provenance: {
|
|
956
|
+
sourceAssetId: input.sourceAsset?.assetId ?? null,
|
|
957
|
+
sourceAsset: input.sourceAsset ?? null,
|
|
958
|
+
messageId: capString(input.messageId, "messageId", 200),
|
|
959
|
+
turnId: capString(input.turnId, "turnId", 200),
|
|
960
|
+
taintLabels: [...new Set(input.taintLabels.map((label, index) => capString(label, `taintLabels[${index}]`, 120)))].sort()
|
|
961
|
+
},
|
|
962
|
+
audience: input.audience,
|
|
963
|
+
createdBy: input.createdBy,
|
|
964
|
+
createdAuthorityRef: input.createdAuthorityRef,
|
|
965
|
+
createdAt: input.now
|
|
966
|
+
};
|
|
550
967
|
return [
|
|
551
968
|
{
|
|
552
969
|
t: "update",
|
|
553
970
|
ns: BRAND_NS.proposal,
|
|
554
971
|
id: input.id,
|
|
555
|
-
attrs: {
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
kind: "palette",
|
|
559
|
-
status: "open",
|
|
560
|
-
payload,
|
|
561
|
-
rationale,
|
|
562
|
-
audience: input.audience,
|
|
563
|
-
createdBy: input.createdBy,
|
|
564
|
-
createdAt: input.now,
|
|
565
|
-
...input.sourceAssetId ? { sourceAssetId: input.sourceAssetId } : {},
|
|
566
|
-
...input.messageId ? { messageId: input.messageId } : {}
|
|
567
|
-
}
|
|
568
|
-
}
|
|
972
|
+
attrs: { ...proposal, reviewDigest: await brandJsonDigest(proposal) }
|
|
973
|
+
},
|
|
974
|
+
{ t: "link", ns: BRAND_NS.proposal, id: input.id, label: "book", target: input.bookId }
|
|
569
975
|
];
|
|
570
976
|
}
|
|
571
977
|
function acceptProposalOps(input) {
|
|
572
|
-
const {
|
|
978
|
+
const {
|
|
979
|
+
proposal,
|
|
980
|
+
paletteId,
|
|
981
|
+
resolvedBy,
|
|
982
|
+
approvalReceiptId,
|
|
983
|
+
approvalActionDigest,
|
|
984
|
+
now
|
|
985
|
+
} = input;
|
|
573
986
|
if (proposal.kind !== "palette")
|
|
574
987
|
throw new BrandInputError(`proposal ${proposal.id} is a ${proposal.kind} proposal, not a palette`);
|
|
575
988
|
if (proposal.status !== "open")
|
|
@@ -578,7 +991,7 @@ function acceptProposalOps(input) {
|
|
|
578
991
|
const name = capString(payload.name, "payload.name", 120);
|
|
579
992
|
const swatches = assertSwatches(payload.swatches);
|
|
580
993
|
const seedHex = payload.seedHex === void 0 ? void 0 : assertHex(payload.seedHex, "payload.seedHex");
|
|
581
|
-
const source = proposal.
|
|
994
|
+
const source = proposal.provenance.sourceAsset ? "extracted" : seedHex ? "derived" : "manual";
|
|
582
995
|
return [
|
|
583
996
|
{
|
|
584
997
|
t: "update",
|
|
@@ -593,12 +1006,19 @@ function acceptProposalOps(input) {
|
|
|
593
1006
|
source,
|
|
594
1007
|
rationale: proposal.rationale,
|
|
595
1008
|
proposalId: proposal.id,
|
|
1009
|
+
approvalReceiptId: capString(approvalReceiptId, "approvalReceiptId", 200),
|
|
1010
|
+
approvalActionDigest: capString(
|
|
1011
|
+
approvalActionDigest,
|
|
1012
|
+
"approvalActionDigest",
|
|
1013
|
+
80
|
|
1014
|
+
),
|
|
596
1015
|
audience: proposal.audience,
|
|
597
1016
|
createdAt: now,
|
|
598
1017
|
updatedAt: now,
|
|
599
1018
|
...seedHex ? { seedHex } : {}
|
|
600
1019
|
}
|
|
601
1020
|
},
|
|
1021
|
+
{ t: "link", ns: BRAND_NS.palette, id: paletteId, label: "book", target: proposal.bookId },
|
|
602
1022
|
...upsertSectionOps({
|
|
603
1023
|
bookId: proposal.bookId,
|
|
604
1024
|
kind: "palette",
|
|
@@ -606,6 +1026,8 @@ function acceptProposalOps(input) {
|
|
|
606
1026
|
status: "approved",
|
|
607
1027
|
audience: proposal.audience,
|
|
608
1028
|
updatedBy: resolvedBy,
|
|
1029
|
+
approvalReceiptId,
|
|
1030
|
+
approvalActionDigest,
|
|
609
1031
|
now
|
|
610
1032
|
}),
|
|
611
1033
|
{
|
|
@@ -653,6 +1075,8 @@ function createAssetOps(input) {
|
|
|
653
1075
|
const contentType = assertAssetContentType(input.contentType);
|
|
654
1076
|
if (typeof input.size !== "number" || !Number.isFinite(input.size) || input.size <= 0)
|
|
655
1077
|
throw new BrandInputError("size must be a positive byte count");
|
|
1078
|
+
if (!/^sha256:[0-9a-f]{64}$/.test(input.contentDigest))
|
|
1079
|
+
throw new BrandInputError("contentDigest must be a SHA-256 digest");
|
|
656
1080
|
const title = input.title === void 0 ? void 0 : capString(input.title, "title", 160);
|
|
657
1081
|
return [
|
|
658
1082
|
{
|
|
@@ -664,27 +1088,68 @@ function createAssetOps(input) {
|
|
|
664
1088
|
bookId: input.bookId,
|
|
665
1089
|
kind: input.kind,
|
|
666
1090
|
path: capString(input.path, "path", 512),
|
|
667
|
-
|
|
1091
|
+
storageObjectId: capString(input.storageObjectId, "storageObjectId", 200),
|
|
1092
|
+
contentDigest: capString(input.contentDigest, "contentDigest", 80),
|
|
668
1093
|
contentType,
|
|
669
1094
|
size: input.size,
|
|
1095
|
+
status: "live",
|
|
1096
|
+
analysisRevision: 0,
|
|
670
1097
|
audience: input.audience,
|
|
671
1098
|
uploadedBy: input.uploadedBy,
|
|
1099
|
+
uploadedAuthorityRef: capString(
|
|
1100
|
+
input.uploadedAuthorityRef,
|
|
1101
|
+
"uploadedAuthorityRef",
|
|
1102
|
+
200
|
|
1103
|
+
),
|
|
672
1104
|
createdAt: input.now,
|
|
673
1105
|
...title ? { title } : {}
|
|
674
1106
|
}
|
|
675
|
-
}
|
|
1107
|
+
},
|
|
1108
|
+
{ t: "link", ns: BRAND_NS.asset, id: input.id, label: "book", target: input.bookId }
|
|
676
1109
|
];
|
|
677
1110
|
}
|
|
678
|
-
function
|
|
679
|
-
return [{
|
|
680
|
-
|
|
681
|
-
|
|
1111
|
+
function beginAssetDeleteOps(assetId, now, deletedBy, deletedAuthorityRef) {
|
|
1112
|
+
return [{
|
|
1113
|
+
t: "update",
|
|
1114
|
+
ns: BRAND_NS.asset,
|
|
1115
|
+
id: assetId,
|
|
1116
|
+
attrs: {
|
|
1117
|
+
status: "deleting",
|
|
1118
|
+
deletedAt: now,
|
|
1119
|
+
deletedBy: capString(deletedBy, "deletedBy", 160),
|
|
1120
|
+
deletedAuthorityRef: capString(deletedAuthorityRef, "deletedAuthorityRef", 200)
|
|
1121
|
+
}
|
|
1122
|
+
}];
|
|
1123
|
+
}
|
|
1124
|
+
function finishAssetDeleteOps(assetId) {
|
|
1125
|
+
return [{
|
|
1126
|
+
t: "update",
|
|
1127
|
+
ns: BRAND_NS.asset,
|
|
1128
|
+
id: assetId,
|
|
1129
|
+
attrs: { status: "deleted" }
|
|
1130
|
+
}];
|
|
1131
|
+
}
|
|
1132
|
+
async function recordAnalysisOps(assetId, analysis, priorRevision, analyzedBy, analyzedAuthorityRef, now) {
|
|
1133
|
+
if (!Number.isSafeInteger(priorRevision) || priorRevision < 0)
|
|
1134
|
+
throw new BrandInputError("prior analysis revision must be a non-negative integer");
|
|
1135
|
+
const normalized = assertAnalysis(analysis);
|
|
682
1136
|
return [
|
|
683
1137
|
{
|
|
684
1138
|
t: "update",
|
|
685
1139
|
ns: BRAND_NS.asset,
|
|
686
1140
|
id: assetId,
|
|
687
|
-
attrs: {
|
|
1141
|
+
attrs: {
|
|
1142
|
+
analysis: normalized,
|
|
1143
|
+
analysisRevision: priorRevision + 1,
|
|
1144
|
+
analysisDigest: await brandJsonDigest(normalized),
|
|
1145
|
+
analyzedBy: capString(analyzedBy, "analyzedBy", 160),
|
|
1146
|
+
analyzedAuthorityRef: capString(
|
|
1147
|
+
analyzedAuthorityRef,
|
|
1148
|
+
"analyzedAuthorityRef",
|
|
1149
|
+
200
|
|
1150
|
+
),
|
|
1151
|
+
analyzedAt: now
|
|
1152
|
+
}
|
|
688
1153
|
}
|
|
689
1154
|
];
|
|
690
1155
|
}
|
|
@@ -723,11 +1188,15 @@ function viewOutput(asset, bytes, contentType) {
|
|
|
723
1188
|
}
|
|
724
1189
|
function assetTools(ctx) {
|
|
725
1190
|
const loadAsset = async (assetId) => {
|
|
1191
|
+
const book = await ctx.loadBook();
|
|
726
1192
|
const res = await ctx.db.query({
|
|
727
|
-
[BRAND_NS.asset]: {
|
|
1193
|
+
[BRAND_NS.asset]: {
|
|
1194
|
+
$: { where: { id: assetId, bookId: ctx.bookId } },
|
|
1195
|
+
book: {}
|
|
1196
|
+
}
|
|
728
1197
|
});
|
|
729
1198
|
const row = (res[BRAND_NS.asset] ?? [])[0];
|
|
730
|
-
if (!row || row.deletedAt) throw new BrandNotFoundError(`asset ${assetId}`);
|
|
1199
|
+
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}`);
|
|
731
1200
|
return row;
|
|
732
1201
|
};
|
|
733
1202
|
const viewAsset = {
|
|
@@ -746,8 +1215,8 @@ function assetTools(ctx) {
|
|
|
746
1215
|
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.`
|
|
747
1216
|
};
|
|
748
1217
|
}
|
|
749
|
-
const
|
|
750
|
-
const fetched = await ctx.
|
|
1218
|
+
const read = await ctx.authority("brand.read");
|
|
1219
|
+
const fetched = await ctx.readAssetContent(asset.id);
|
|
751
1220
|
if (fetched.bytes.byteLength > MAX_VIEW_BYTES) {
|
|
752
1221
|
return {
|
|
753
1222
|
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.`
|
|
@@ -765,7 +1234,8 @@ function assetTools(ctx) {
|
|
|
765
1234
|
};
|
|
766
1235
|
const recordAnalysis = {
|
|
767
1236
|
name: "record_asset_analysis",
|
|
768
|
-
description: "Record what you observed in an asset you viewed
|
|
1237
|
+
description: "Record what you observed in an asset you viewed. Uses guarded analysis revisions so concurrent agents cannot overwrite each other silently.",
|
|
1238
|
+
acceptsTaint: ["tool_untrusted:view_asset"],
|
|
769
1239
|
inputSchema: {
|
|
770
1240
|
type: "object",
|
|
771
1241
|
required: ["assetId", "description", "dominantColors", "tags"],
|
|
@@ -778,12 +1248,19 @@ function assetTools(ctx) {
|
|
|
778
1248
|
},
|
|
779
1249
|
handler: ctx.guard(async (input) => {
|
|
780
1250
|
const asset = await loadAsset(String(input.assetId));
|
|
1251
|
+
await ctx.authority("brand.edit");
|
|
781
1252
|
const analysis = assertAnalysis({
|
|
782
1253
|
description: input.description,
|
|
783
1254
|
dominantColors: input.dominantColors,
|
|
784
1255
|
tags: input.tags
|
|
785
1256
|
});
|
|
786
|
-
|
|
1257
|
+
const updated = await ctx.recordAssetAnalysis({
|
|
1258
|
+
mutationId: ctx.newId(),
|
|
1259
|
+
assetId: asset.id,
|
|
1260
|
+
analysis,
|
|
1261
|
+
expectedAnalysisRevision: asset.analysisRevision
|
|
1262
|
+
});
|
|
1263
|
+
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");
|
|
787
1264
|
return {
|
|
788
1265
|
content: `Recorded analysis for asset ${asset.id}: ${analysis.dominantColors.length} dominant color(s), ${analysis.tags.length} tag(s).`
|
|
789
1266
|
};
|
|
@@ -792,81 +1269,77 @@ function assetTools(ctx) {
|
|
|
792
1269
|
return [viewAsset, recordAnalysis];
|
|
793
1270
|
}
|
|
794
1271
|
|
|
1272
|
+
// src/skill/source-asset.ts
|
|
1273
|
+
async function proposalSourceAssetId(ctx, book, value) {
|
|
1274
|
+
if (value === void 0) return void 0;
|
|
1275
|
+
const sourceAssetId = capString(value, "sourceAssetId", 200);
|
|
1276
|
+
const result = await ctx.db.query({
|
|
1277
|
+
[BRAND_NS.asset]: {
|
|
1278
|
+
$: {
|
|
1279
|
+
where: { id: sourceAssetId, bookId: book.id, status: "live" },
|
|
1280
|
+
limit: 2
|
|
1281
|
+
},
|
|
1282
|
+
book: { $: { limit: 2 } }
|
|
1283
|
+
}
|
|
1284
|
+
});
|
|
1285
|
+
const rows = result[BRAND_NS.asset] ?? [];
|
|
1286
|
+
const asset = rows.length === 1 ? rows[0] : void 0;
|
|
1287
|
+
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(
|
|
1288
|
+
"sourceAssetId must be a live asset in this brand book"
|
|
1289
|
+
);
|
|
1290
|
+
return sourceAssetId;
|
|
1291
|
+
}
|
|
1292
|
+
|
|
795
1293
|
// src/skill/book-tools.ts
|
|
1294
|
+
var PROPOSABLE = SECTION_KINDS.filter((kind) => kind !== "palette");
|
|
796
1295
|
function bookTools(ctx) {
|
|
797
|
-
|
|
1296
|
+
return [{
|
|
798
1297
|
name: "update_section",
|
|
799
|
-
description: "
|
|
1298
|
+
description: "Propose a typography, voice, logo, or imagery section revision for human approval. This never overwrites the live approved section.",
|
|
1299
|
+
acceptsTaint: ["tool_untrusted:view_asset"],
|
|
800
1300
|
inputSchema: {
|
|
801
1301
|
type: "object",
|
|
802
|
-
required: ["kind", "content"],
|
|
1302
|
+
required: ["kind", "content", "rationale"],
|
|
803
1303
|
properties: {
|
|
804
|
-
kind: { type: "string", enum:
|
|
805
|
-
content: { type: "object", description: "The section payload
|
|
806
|
-
|
|
1304
|
+
kind: { type: "string", enum: PROPOSABLE },
|
|
1305
|
+
content: { type: "object", description: "The complete proposed section payload." },
|
|
1306
|
+
rationale: { type: "string", description: "Why this revision should replace the live section." },
|
|
1307
|
+
sourceAssetId: {
|
|
1308
|
+
type: "string",
|
|
1309
|
+
description: "Optional exact live Brand asset that grounds this revision."
|
|
1310
|
+
}
|
|
807
1311
|
}
|
|
808
1312
|
},
|
|
809
1313
|
handler: ctx.guard(async (input) => {
|
|
1314
|
+
const allowed = /* @__PURE__ */ new Set(["kind", "content", "rationale", "sourceAssetId"]);
|
|
1315
|
+
if (Object.keys(input).some((key) => !allowed.has(key)))
|
|
1316
|
+
throw new BrandInputError("section proposal has unknown fields");
|
|
810
1317
|
const book = await ctx.loadBook();
|
|
811
|
-
|
|
812
|
-
const
|
|
813
|
-
|
|
1318
|
+
await ctx.authority("brand.edit");
|
|
1319
|
+
const kind = input.kind;
|
|
1320
|
+
if (kind === "palette" || !PROPOSABLE.includes(kind))
|
|
1321
|
+
throw new BrandInputError(`kind must be one of: ${PROPOSABLE.join(", ")}`);
|
|
1322
|
+
const rationale = capString(input.rationale, "rationale", 2e3);
|
|
1323
|
+
const sourceAssetId = await proposalSourceAssetId(
|
|
1324
|
+
ctx,
|
|
1325
|
+
book,
|
|
1326
|
+
input.sourceAssetId
|
|
1327
|
+
);
|
|
1328
|
+
const proposal = await ctx.createProposal({
|
|
1329
|
+
mutationId: ctx.newId(),
|
|
814
1330
|
kind,
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
});
|
|
821
|
-
await ctx.db.transact(ops, { mutationId: ctx.newId() });
|
|
822
|
-
const status = input.status === void 0 ? "draft" : String(input.status);
|
|
823
|
-
return { content: `Saved ${kind} section as ${status} (key ${sectionKey(ctx.bookId, kind)}).` };
|
|
824
|
-
})
|
|
825
|
-
};
|
|
826
|
-
const compileTokens = {
|
|
827
|
-
name: "compile_tokens",
|
|
828
|
-
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.",
|
|
829
|
-
inputSchema: {
|
|
830
|
-
type: "object",
|
|
831
|
-
properties: { paletteId: { type: "string", description: "Defaults to the book's active palette." } }
|
|
832
|
-
},
|
|
833
|
-
handler: ctx.guard(async (input) => {
|
|
834
|
-
const book = await ctx.loadBook();
|
|
835
|
-
const paletteId = input.paletteId === void 0 ? book.activePaletteId : capString(input.paletteId, "paletteId", 128);
|
|
836
|
-
if (!paletteId) {
|
|
837
|
-
throw new BrandInputError("no palette to compile: pass paletteId, or get a palette proposal accepted first");
|
|
838
|
-
}
|
|
839
|
-
const pres = await ctx.db.query({
|
|
840
|
-
[BRAND_NS.palette]: { $: { where: { id: paletteId, bookId: ctx.bookId } } }
|
|
841
|
-
});
|
|
842
|
-
const palette = (pres[BRAND_NS.palette] ?? [])[0];
|
|
843
|
-
if (!palette) throw new BrandNotFoundError(`palette ${paletteId}`);
|
|
844
|
-
const sres = await ctx.db.query({
|
|
845
|
-
[BRAND_NS.section]: { $: { where: { key: sectionKey(ctx.bookId, "typography") } } }
|
|
846
|
-
});
|
|
847
|
-
const typography = (sres[BRAND_NS.section] ?? [])[0];
|
|
848
|
-
const compiled = compileBrandTokens({
|
|
849
|
-
swatches: palette.swatches,
|
|
850
|
-
...typography ? { typography: typography.content } : {}
|
|
1331
|
+
payload: {
|
|
1332
|
+
content: assertSectionContent(kind, input.content)
|
|
1333
|
+
},
|
|
1334
|
+
rationale,
|
|
1335
|
+
...sourceAssetId ? { sourceAssetId } : {}
|
|
851
1336
|
});
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
dark: compiled.dark,
|
|
856
|
-
warnings: compiled.warnings,
|
|
857
|
-
compiledAt: now
|
|
1337
|
+
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");
|
|
1338
|
+
return {
|
|
1339
|
+
content: `Parked ${kind} section proposal ${proposal.id}. The live section is unchanged; a human must approve this exact revision.`
|
|
858
1340
|
};
|
|
859
|
-
await ctx.db.transact(updateBookOps(ctx.bookId, { tokens: snapshot }, now), { mutationId: ctx.newId() });
|
|
860
|
-
const count = compiled.warnings.length;
|
|
861
|
-
const lines = [
|
|
862
|
-
`Compiled ${Object.keys(compiled.light).length} light + ${Object.keys(compiled.dark).length} dark tokens from palette ${paletteId}; ${count} warning(s).`,
|
|
863
|
-
...compiled.warnings.slice(0, 3).map((w) => `- ${w.token}: ${w.message}${w.adjustedFrom ? ` (adjusted from ${w.adjustedFrom})` : ""}`)
|
|
864
|
-
];
|
|
865
|
-
if (count > 3) lines.push(`\u2026 and ${count - 3} more.`);
|
|
866
|
-
return { content: lines.join("\n") };
|
|
867
1341
|
})
|
|
868
|
-
};
|
|
869
|
-
return [updateSection, compileTokens];
|
|
1342
|
+
}];
|
|
870
1343
|
}
|
|
871
1344
|
|
|
872
1345
|
// src/skill/palette-tools.ts
|
|
@@ -994,6 +1467,7 @@ function paletteTools(ctx) {
|
|
|
994
1467
|
const proposePalette = {
|
|
995
1468
|
name: "propose_palette",
|
|
996
1469
|
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.",
|
|
1470
|
+
acceptsTaint: ["tool_untrusted:view_asset"],
|
|
997
1471
|
inputSchema: {
|
|
998
1472
|
type: "object",
|
|
999
1473
|
required: ["name", "rationale"],
|
|
@@ -1002,6 +1476,10 @@ function paletteTools(ctx) {
|
|
|
1002
1476
|
rationale: { type: "string", description: "Why these colors \u2014 cite assets, harmony, contrast." },
|
|
1003
1477
|
seedHex: { type: "string" },
|
|
1004
1478
|
harmony: { type: "string", enum: Object.keys(HARMONY_COMPANIONS) },
|
|
1479
|
+
sourceAssetId: {
|
|
1480
|
+
type: "string",
|
|
1481
|
+
description: "Optional exact asset id that grounded this palette."
|
|
1482
|
+
},
|
|
1005
1483
|
swatches: {
|
|
1006
1484
|
type: "array",
|
|
1007
1485
|
items: {
|
|
@@ -1017,8 +1495,16 @@ function paletteTools(ctx) {
|
|
|
1017
1495
|
}
|
|
1018
1496
|
}
|
|
1019
1497
|
},
|
|
1020
|
-
handler: ctx.guard(async (input) => {
|
|
1498
|
+
handler: ctx.guard(async (input, toolCtx) => {
|
|
1021
1499
|
const book = await ctx.loadBook();
|
|
1500
|
+
await ctx.authority("brand.edit");
|
|
1501
|
+
const name = capString(input.name, "name", 120);
|
|
1502
|
+
const rationale = capString(input.rationale, "rationale", 2e3);
|
|
1503
|
+
const sourceAssetId = await proposalSourceAssetId(
|
|
1504
|
+
ctx,
|
|
1505
|
+
book,
|
|
1506
|
+
input.sourceAssetId
|
|
1507
|
+
);
|
|
1022
1508
|
const seedHex = input.seedHex === void 0 ? void 0 : assertHex(input.seedHex, "seedHex");
|
|
1023
1509
|
let swatches;
|
|
1024
1510
|
if (input.swatches !== void 0) {
|
|
@@ -1029,84 +1515,53 @@ function paletteTools(ctx) {
|
|
|
1029
1515
|
if (input.harmony !== void 0) swatches = applyHarmony(swatches, seedHex, input.harmony);
|
|
1030
1516
|
}
|
|
1031
1517
|
const report = contrastReport(swatches);
|
|
1032
|
-
const
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
now: ctx.now()
|
|
1518
|
+
const proposal = await ctx.createProposal({
|
|
1519
|
+
mutationId: ctx.newId(),
|
|
1520
|
+
kind: "palette",
|
|
1521
|
+
payload: {
|
|
1522
|
+
name,
|
|
1523
|
+
swatches,
|
|
1524
|
+
...seedHex ? { seedHex } : {},
|
|
1525
|
+
contrastReport: report
|
|
1526
|
+
},
|
|
1527
|
+
rationale,
|
|
1528
|
+
...sourceAssetId ? { sourceAssetId } : {}
|
|
1044
1529
|
});
|
|
1045
|
-
|
|
1530
|
+
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");
|
|
1046
1531
|
const reportText = Object.entries(report).map(([k, v]) => `${k} ${r2(v)}:1`).join(", ");
|
|
1047
1532
|
return {
|
|
1048
|
-
content: `Parked palette proposal ${id} ("${
|
|
1533
|
+
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.`
|
|
1049
1534
|
};
|
|
1050
1535
|
})
|
|
1051
1536
|
};
|
|
1052
|
-
|
|
1053
|
-
name: "resolve_proposal",
|
|
1054
|
-
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.",
|
|
1055
|
-
inputSchema: {
|
|
1056
|
-
type: "object",
|
|
1057
|
-
required: ["proposalId", "resolution"],
|
|
1058
|
-
properties: {
|
|
1059
|
-
proposalId: { type: "string" },
|
|
1060
|
-
resolution: { type: "string", enum: ["accepted", "rejected"] },
|
|
1061
|
-
note: { type: "string" }
|
|
1062
|
-
}
|
|
1063
|
-
},
|
|
1064
|
-
handler: ctx.guard(async (input) => {
|
|
1065
|
-
const proposalId = String(input.proposalId);
|
|
1066
|
-
const res = await ctx.db.query({
|
|
1067
|
-
[BRAND_NS.proposal]: { $: { where: { id: proposalId, bookId: ctx.bookId } } }
|
|
1068
|
-
});
|
|
1069
|
-
const proposal = (res[BRAND_NS.proposal] ?? [])[0];
|
|
1070
|
-
if (!proposal) throw new BrandNotFoundError(`proposal ${proposalId}`);
|
|
1071
|
-
const note = input.note === void 0 ? void 0 : capString(input.note, "note", 1e3);
|
|
1072
|
-
const now = ctx.now();
|
|
1073
|
-
if (input.resolution === "accepted") {
|
|
1074
|
-
const paletteId = ctx.newId();
|
|
1075
|
-
const ops = acceptProposalOps({ proposal, paletteId, resolvedBy: ctx.self.selfId, now, resolutionNote: note });
|
|
1076
|
-
await ctx.db.transact(ops, { mutationId: paletteId });
|
|
1077
|
-
return { content: `Proposal ${proposalId} accepted \u2014 palette ${paletteId} written and set as the book's active palette.` };
|
|
1078
|
-
}
|
|
1079
|
-
if (input.resolution === "rejected") {
|
|
1080
|
-
const ops = rejectProposalOps({ proposal, resolvedBy: ctx.self.selfId, now, resolutionNote: note });
|
|
1081
|
-
await ctx.db.transact(ops, { mutationId: ctx.newId() });
|
|
1082
|
-
return { content: `Proposal ${proposalId} rejected.` };
|
|
1083
|
-
}
|
|
1084
|
-
throw new BrandInputError('resolution must be "accepted" or "rejected"');
|
|
1085
|
-
})
|
|
1086
|
-
};
|
|
1087
|
-
return [analyzeColor, evaluateContrast, proposePalette, resolveProposal];
|
|
1537
|
+
return [analyzeColor, evaluateContrast, proposePalette];
|
|
1088
1538
|
}
|
|
1089
1539
|
|
|
1090
1540
|
// src/skill/read-tools.ts
|
|
1091
1541
|
var iso = (ms) => new Date(ms).toISOString();
|
|
1092
|
-
|
|
1542
|
+
var shortId = (id) => id.length <= 12 ? id : `${id.slice(0, 6)}\u2026${id.slice(-4)}`;
|
|
1543
|
+
var principalLabel = (id, directory) => {
|
|
1544
|
+
const value = directory.get(id);
|
|
1545
|
+
return value ? `${value.displayName} [${value.kind} \xB7 ${shortId(id)}]` : `Unknown principal [${shortId(id)}]`;
|
|
1546
|
+
};
|
|
1547
|
+
function bookLines(book, directory) {
|
|
1093
1548
|
const lines = [
|
|
1094
1549
|
`brand book "${book.name}" (${book.slug}) \u2014 ${book.status}`,
|
|
1095
|
-
`members: ${book.memberIds.join(", ")}`,
|
|
1550
|
+
`members: ${book.memberIds.map((id) => principalLabel(id, directory)).join(", ")}`,
|
|
1096
1551
|
`active palette: ${book.activePaletteId ?? "(none accepted yet)"}`,
|
|
1097
1552
|
book.tokens ? `tokens: compiled ${iso(book.tokens.compiledAt)} with ${book.tokens.warnings.length} warning(s)` : "tokens: (not compiled)"
|
|
1098
1553
|
];
|
|
1099
1554
|
if (book.summary) lines.push(`summary: ${book.summary}`);
|
|
1100
1555
|
return lines;
|
|
1101
1556
|
}
|
|
1102
|
-
function childLines(sections, palettes, proposals) {
|
|
1557
|
+
function childLines(sections, palettes, proposals, directory) {
|
|
1103
1558
|
return [
|
|
1104
1559
|
"sections:",
|
|
1105
|
-
...sections.length ? sections.map((s) => `- ${s.kind}: ${s.status}, updated ${iso(s.updatedAt)} by
|
|
1560
|
+
...sections.length ? sections.map((s) => `- ${s.kind}: ${s.status}, updated ${iso(s.updatedAt)} by ` + principalLabel(s.updatedBy, directory)) : ["- (none)"],
|
|
1106
1561
|
"palettes:",
|
|
1107
1562
|
...palettes.length ? palettes.map((p) => `- ${p.id} "${p.name}" (${p.status}, ${p.source}, ${p.swatches.length} swatches)`) : ["- (none)"],
|
|
1108
1563
|
"open proposals:",
|
|
1109
|
-
...proposals.length ? proposals.map((p) => `- ${p.id} (${p.kind}) by ${p.createdBy}: ${p.rationale}`) : ["- (none)"]
|
|
1564
|
+
...proposals.length ? proposals.map((p) => `- ${p.id} (${p.kind}) by ${principalLabel(p.createdBy, directory)}: ${p.rationale}`) : ["- (none)"]
|
|
1110
1565
|
];
|
|
1111
1566
|
}
|
|
1112
1567
|
function readTools(ctx) {
|
|
@@ -1115,18 +1570,48 @@ function readTools(ctx) {
|
|
|
1115
1570
|
description: "Read the current brand book: status, members, active palette, compiled tokens, sections, palettes, and open proposals.",
|
|
1116
1571
|
inputSchema: { type: "object", properties: {} },
|
|
1117
1572
|
handler: ctx.guard(async () => {
|
|
1573
|
+
const authorizedBook = await ctx.loadBook();
|
|
1118
1574
|
const res = await ctx.db.query({
|
|
1119
|
-
[BRAND_NS.
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1575
|
+
[BRAND_NS.section]: {
|
|
1576
|
+
$: { where: { bookId: ctx.bookId }, order: { updatedAt: "asc" } },
|
|
1577
|
+
book: {}
|
|
1578
|
+
},
|
|
1579
|
+
[BRAND_NS.palette]: {
|
|
1580
|
+
$: { where: { bookId: ctx.bookId }, order: { createdAt: "asc" } },
|
|
1581
|
+
book: {}
|
|
1582
|
+
},
|
|
1583
|
+
[BRAND_NS.proposal]: {
|
|
1584
|
+
$: {
|
|
1585
|
+
where: { bookId: ctx.bookId, status: "open" },
|
|
1586
|
+
order: { createdAt: "asc" }
|
|
1587
|
+
},
|
|
1588
|
+
book: {}
|
|
1589
|
+
}
|
|
1123
1590
|
});
|
|
1124
|
-
const
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
const
|
|
1129
|
-
|
|
1591
|
+
const linked = (rows) => rows.filter((row) => row.bookId === authorizedBook.id && Array.isArray(row.book) && row.book.length === 1 && row.book[0]?.id === authorizedBook.id);
|
|
1592
|
+
const sections = linked(
|
|
1593
|
+
res[BRAND_NS.section] ?? []
|
|
1594
|
+
);
|
|
1595
|
+
const palettes = linked(
|
|
1596
|
+
res[BRAND_NS.palette] ?? []
|
|
1597
|
+
);
|
|
1598
|
+
const proposals = linked(
|
|
1599
|
+
res[BRAND_NS.proposal] ?? []
|
|
1600
|
+
);
|
|
1601
|
+
const ids = [
|
|
1602
|
+
...authorizedBook.memberIds,
|
|
1603
|
+
...sections.map((section) => section.updatedBy),
|
|
1604
|
+
...proposals.map((proposal) => proposal.createdBy)
|
|
1605
|
+
];
|
|
1606
|
+
const directory = new Map(
|
|
1607
|
+
(await ctx.resolvePrincipals(ids)).map((principal) => [principal.id, principal])
|
|
1608
|
+
);
|
|
1609
|
+
return {
|
|
1610
|
+
content: [
|
|
1611
|
+
...bookLines(authorizedBook, directory),
|
|
1612
|
+
...childLines(sections, palettes, proposals, directory)
|
|
1613
|
+
].join("\n")
|
|
1614
|
+
};
|
|
1130
1615
|
})
|
|
1131
1616
|
};
|
|
1132
1617
|
const listAssets = {
|
|
@@ -1139,15 +1624,21 @@ function readTools(ctx) {
|
|
|
1139
1624
|
}
|
|
1140
1625
|
},
|
|
1141
1626
|
handler: ctx.guard(async (input) => {
|
|
1142
|
-
|
|
1627
|
+
await ctx.loadBook();
|
|
1628
|
+
const where = { bookId: ctx.bookId, status: "live" };
|
|
1143
1629
|
if (input.kind !== void 0) {
|
|
1144
1630
|
if (typeof input.kind !== "string" || !ASSET_KINDS.includes(input.kind)) {
|
|
1145
1631
|
throw new BrandInputError(`kind must be one of: ${ASSET_KINDS.join(", ")}`);
|
|
1146
1632
|
}
|
|
1147
1633
|
where.kind = input.kind;
|
|
1148
1634
|
}
|
|
1149
|
-
const res = await ctx.db.query({
|
|
1150
|
-
|
|
1635
|
+
const res = await ctx.db.query({
|
|
1636
|
+
[BRAND_NS.asset]: {
|
|
1637
|
+
$: { where, order: { createdAt: "asc" } },
|
|
1638
|
+
book: {}
|
|
1639
|
+
}
|
|
1640
|
+
});
|
|
1641
|
+
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);
|
|
1151
1642
|
if (rows.length === 0) {
|
|
1152
1643
|
return { content: input.kind ? `(no ${String(input.kind)} assets uploaded yet)` : "(no assets uploaded yet)" };
|
|
1153
1644
|
}
|
|
@@ -1161,29 +1652,64 @@ function readTools(ctx) {
|
|
|
1161
1652
|
}
|
|
1162
1653
|
|
|
1163
1654
|
// src/skill/skill.ts
|
|
1164
|
-
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.
|
|
1655
|
+
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.";
|
|
1165
1656
|
function brandSkill(opts) {
|
|
1166
|
-
|
|
1657
|
+
if (opts.agentDbBinding.principalId !== opts.self.selfId || !opts.agentDbBinding.credentialRef) throw new BrandForbiddenError("brand db is not bound to the acting agent");
|
|
1658
|
+
const deps = resolveDeps({ db: opts.db, now: opts.now, newId: opts.newId });
|
|
1659
|
+
const authority = (capability) => Promise.resolve(opts.authorizeCapability({
|
|
1660
|
+
agentId: opts.self.selfId,
|
|
1661
|
+
bookId: opts.bookId,
|
|
1662
|
+
capability
|
|
1663
|
+
}) ?? null).then((result) => {
|
|
1664
|
+
if (!result || result.capability !== capability || !result.authorityRef)
|
|
1665
|
+
throw new BrandForbiddenError(`agent lacks ${capability} for brand book ${opts.bookId}`);
|
|
1666
|
+
return result;
|
|
1667
|
+
});
|
|
1167
1668
|
const ctx = {
|
|
1168
1669
|
db: deps.db,
|
|
1169
1670
|
bookId: opts.bookId,
|
|
1170
1671
|
self: opts.self,
|
|
1171
|
-
fileBaseUrl: opts.fileBaseUrl,
|
|
1172
|
-
fetchBytes: deps.fetchBytes,
|
|
1173
1672
|
visionInToolResults: opts.visionInToolResults !== false,
|
|
1174
1673
|
now: deps.now,
|
|
1175
1674
|
newId: deps.newId,
|
|
1675
|
+
agentJobId: opts.agentJobId,
|
|
1676
|
+
createProposal: (input) => opts.agentBridge.createProposal({
|
|
1677
|
+
jobId: opts.agentJobId,
|
|
1678
|
+
bookId: opts.bookId,
|
|
1679
|
+
...input
|
|
1680
|
+
}),
|
|
1681
|
+
recordAssetAnalysis: (input) => opts.agentBridge.recordAssetAnalysis({
|
|
1682
|
+
jobId: opts.agentJobId,
|
|
1683
|
+
bookId: opts.bookId,
|
|
1684
|
+
...input
|
|
1685
|
+
}),
|
|
1686
|
+
readAssetContent: (assetId) => opts.agentBridge.readAssetContent({
|
|
1687
|
+
jobId: opts.agentJobId,
|
|
1688
|
+
bookId: opts.bookId,
|
|
1689
|
+
assetId
|
|
1690
|
+
}),
|
|
1691
|
+
resolvePrincipals: (principalIds) => {
|
|
1692
|
+
const ids = [...new Set(principalIds)].slice(0, 100);
|
|
1693
|
+
return opts.resolvePrincipals({
|
|
1694
|
+
requesterAgentId: opts.self.selfId,
|
|
1695
|
+
bookId: opts.bookId,
|
|
1696
|
+
principalIds: ids
|
|
1697
|
+
});
|
|
1698
|
+
},
|
|
1699
|
+
authority,
|
|
1176
1700
|
loadBook: async () => {
|
|
1701
|
+
await authority("brand.read");
|
|
1177
1702
|
const res = await deps.db.query({ [BRAND_NS.book]: { $: { where: { id: opts.bookId } } } });
|
|
1178
1703
|
const row = (res[BRAND_NS.book] ?? [])[0];
|
|
1179
|
-
if (!row
|
|
1704
|
+
if (!row || !row.memberIds.includes(opts.self.selfId))
|
|
1705
|
+
throw new BrandNotFoundError(`brand book ${opts.bookId}`);
|
|
1180
1706
|
return row;
|
|
1181
1707
|
},
|
|
1182
1708
|
guard: (handler) => async (input, toolCtx) => {
|
|
1183
1709
|
try {
|
|
1184
1710
|
return await handler(input, toolCtx);
|
|
1185
1711
|
} catch (error) {
|
|
1186
|
-
if (error instanceof BrandInputError || error instanceof BrandNotFoundError) {
|
|
1712
|
+
if (error instanceof BrandInputError || error instanceof BrandNotFoundError || error instanceof BrandForbiddenError) {
|
|
1187
1713
|
return { content: error.message, isError: true };
|
|
1188
1714
|
}
|
|
1189
1715
|
throw error;
|
|
@@ -1198,7 +1724,7 @@ function brandSkill(opts) {
|
|
|
1198
1724
|
}
|
|
1199
1725
|
|
|
1200
1726
|
// src/skill/persona.ts
|
|
1201
|
-
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
|
|
1727
|
+
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.";
|
|
1202
1728
|
function createBrandPersona(opts) {
|
|
1203
1729
|
return {
|
|
1204
1730
|
name: "brand-director",
|
|
@@ -1214,7 +1740,35 @@ function supportsBrandVision(spec) {
|
|
|
1214
1740
|
return !!(caps?.toolResultBlocks && caps.imageIn && caps.documentIn);
|
|
1215
1741
|
}
|
|
1216
1742
|
|
|
1743
|
+
// src/routes/asset-query.ts
|
|
1744
|
+
async function linkedAsset(ctx, book, assetId, allowDeleting = false) {
|
|
1745
|
+
const result = await ctx.db.query({
|
|
1746
|
+
[BRAND_NS.asset]: {
|
|
1747
|
+
$: { where: { id: assetId, bookId: book.id } },
|
|
1748
|
+
book: {}
|
|
1749
|
+
}
|
|
1750
|
+
});
|
|
1751
|
+
const row = (result[BRAND_NS.asset] ?? [])[0];
|
|
1752
|
+
const links = row?.book;
|
|
1753
|
+
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}`);
|
|
1754
|
+
return row;
|
|
1755
|
+
}
|
|
1756
|
+
|
|
1217
1757
|
// src/routes/http.ts
|
|
1758
|
+
async function requireRouteAuthority(ctx, req, capability, effect, bookId) {
|
|
1759
|
+
if (ctx.actor.kind !== "human")
|
|
1760
|
+
throw new BrandForbiddenError("this brand effect requires a directly authenticated human");
|
|
1761
|
+
const result = await ctx.authorizeCapability({
|
|
1762
|
+
req,
|
|
1763
|
+
actor: ctx.actor,
|
|
1764
|
+
appId: ctx.appId,
|
|
1765
|
+
...bookId ? { bookId } : {},
|
|
1766
|
+
capability,
|
|
1767
|
+
effect
|
|
1768
|
+
});
|
|
1769
|
+
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`);
|
|
1770
|
+
return result;
|
|
1771
|
+
}
|
|
1218
1772
|
var json = (body, status = 200, headers = {}) => new Response(JSON.stringify(body), {
|
|
1219
1773
|
status,
|
|
1220
1774
|
headers: { "content-type": "application/json", ...headers }
|
|
@@ -1222,7 +1776,12 @@ var json = (body, status = 200, headers = {}) => new Response(JSON.stringify(bod
|
|
|
1222
1776
|
var errorResponse = (error) => {
|
|
1223
1777
|
if (error instanceof BrandInputError)
|
|
1224
1778
|
return json({ error: error.message, ...error.fields ? { fields: error.fields } : {} }, 400);
|
|
1779
|
+
if (error instanceof BrandForbiddenError) return json({ error: error.message }, 403);
|
|
1225
1780
|
if (error instanceof BrandNotFoundError) return json({ error: error.message }, 404);
|
|
1781
|
+
if (error instanceof BrandGoneError) return json({ error: error.message }, 410);
|
|
1782
|
+
if (error instanceof BrandReviewStateChangedError)
|
|
1783
|
+
return json({ error: error.message, code: error.code }, 409);
|
|
1784
|
+
if (error instanceof BrandConflictError) return json({ error: error.message }, 409);
|
|
1226
1785
|
return json({ error: "internal error" }, 500);
|
|
1227
1786
|
};
|
|
1228
1787
|
var methodNotAllowed = () => json({ error: "method not allowed" }, 405);
|
|
@@ -1271,86 +1830,217 @@ async function loadMemberBook(db, bookId, actorId) {
|
|
|
1271
1830
|
return book;
|
|
1272
1831
|
}
|
|
1273
1832
|
|
|
1274
|
-
// src/routes/
|
|
1833
|
+
// src/routes/asset-upload.ts
|
|
1275
1834
|
async function readForm(req) {
|
|
1276
1835
|
try {
|
|
1277
1836
|
return await req.formData();
|
|
1278
1837
|
} catch {
|
|
1279
|
-
throw new BrandInputError(
|
|
1838
|
+
throw new BrandInputError(
|
|
1839
|
+
'expected multipart/form-data with a "file" field'
|
|
1840
|
+
);
|
|
1280
1841
|
}
|
|
1281
1842
|
}
|
|
1843
|
+
async function contentDigest(file) {
|
|
1844
|
+
const hash = await crypto.subtle.digest("SHA-256", await file.arrayBuffer());
|
|
1845
|
+
return `sha256:${[...new Uint8Array(hash)].map((byte) => byte.toString(16).padStart(2, "0")).join("")}`;
|
|
1846
|
+
}
|
|
1282
1847
|
async function uploadAsset(ctx, req, bookId) {
|
|
1283
1848
|
const book = await loadMemberBook(ctx.db, bookId, ctx.actor.id);
|
|
1284
1849
|
const form = await readForm(req);
|
|
1285
1850
|
const file = form.get("file");
|
|
1286
|
-
if (!(file instanceof File))
|
|
1851
|
+
if (!(file instanceof File))
|
|
1852
|
+
throw new BrandInputError('"file" must be an uploaded file field');
|
|
1287
1853
|
let contentType;
|
|
1288
1854
|
try {
|
|
1289
1855
|
contentType = assertAssetContentType(file.type);
|
|
1290
1856
|
} catch (error) {
|
|
1291
|
-
if (error instanceof BrandInputError)
|
|
1857
|
+
if (error instanceof BrandInputError)
|
|
1858
|
+
return json({ error: error.message }, 415);
|
|
1292
1859
|
throw error;
|
|
1293
1860
|
}
|
|
1294
1861
|
if (file.size > ctx.maxUploadBytes)
|
|
1295
|
-
return json(
|
|
1296
|
-
{ error: `file is ${file.size} bytes; the upload limit is ${ctx.maxUploadBytes}` },
|
|
1297
|
-
413
|
|
1298
|
-
);
|
|
1862
|
+
return json({ error: `file exceeds ${ctx.maxUploadBytes} bytes` }, 413);
|
|
1299
1863
|
const kindRaw = form.get("kind");
|
|
1300
|
-
const kind = typeof kindRaw === "string" && kindRaw
|
|
1864
|
+
const kind = typeof kindRaw === "string" && kindRaw ? kindRaw : "other";
|
|
1301
1865
|
if (!ASSET_KINDS.includes(kind))
|
|
1302
1866
|
throw new BrandInputError(`kind must be one of: ${ASSET_KINDS.join(", ")}`);
|
|
1303
1867
|
const titleRaw = form.get("title");
|
|
1304
|
-
const title = typeof titleRaw === "string" && titleRaw
|
|
1868
|
+
const title = typeof titleRaw === "string" && titleRaw ? capString(titleRaw, "title", 160) : void 0;
|
|
1869
|
+
const fileName = safeFileName(file.name);
|
|
1305
1870
|
const id = ctx.newId();
|
|
1306
|
-
const path = `brand/${book.id}/assets/${id}/${
|
|
1307
|
-
const
|
|
1308
|
-
|
|
1309
|
-
|
|
1871
|
+
const path = `brand/${book.id}/assets/${id}/${fileName}`;
|
|
1872
|
+
const authority = await requireRouteAuthority(
|
|
1873
|
+
ctx,
|
|
1874
|
+
req,
|
|
1875
|
+
"brand.edit",
|
|
1876
|
+
"internal",
|
|
1877
|
+
book.id
|
|
1878
|
+
);
|
|
1879
|
+
const record6 = await ctx.db.storage.upload(
|
|
1880
|
+
path,
|
|
1881
|
+
file,
|
|
1882
|
+
contentType,
|
|
1883
|
+
{ private: true }
|
|
1884
|
+
);
|
|
1885
|
+
if (record6.path !== path) {
|
|
1886
|
+
await ctx.db.storage.delete(record6.path);
|
|
1887
|
+
throw new BrandConflictError(
|
|
1888
|
+
"private storage returned an unexpected asset path"
|
|
1889
|
+
);
|
|
1890
|
+
}
|
|
1891
|
+
try {
|
|
1892
|
+
const current = await requireRouteAuthority(
|
|
1893
|
+
ctx,
|
|
1894
|
+
req,
|
|
1895
|
+
"brand.edit",
|
|
1896
|
+
"internal",
|
|
1897
|
+
book.id
|
|
1898
|
+
);
|
|
1899
|
+
if (current.authorityRef !== authority.authorityRef)
|
|
1900
|
+
throw new BrandConflictError("brand edit authority changed during upload");
|
|
1901
|
+
await ctx.db.transact(createAssetOps({
|
|
1310
1902
|
id,
|
|
1311
1903
|
bookId: book.id,
|
|
1312
1904
|
kind,
|
|
1313
|
-
path:
|
|
1314
|
-
|
|
1905
|
+
path: record6.path,
|
|
1906
|
+
storageObjectId: record6.id,
|
|
1907
|
+
contentDigest: await contentDigest(file),
|
|
1315
1908
|
contentType,
|
|
1316
|
-
size:
|
|
1909
|
+
size: record6.size,
|
|
1317
1910
|
uploadedBy: ctx.actor.id,
|
|
1911
|
+
uploadedAuthorityRef: authority.authorityRef,
|
|
1318
1912
|
audience: book.memberIds,
|
|
1319
1913
|
title,
|
|
1320
1914
|
now: ctx.now()
|
|
1321
|
-
}),
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1915
|
+
}), {
|
|
1916
|
+
mutationId: `brand:asset-upload:v1:${id}`,
|
|
1917
|
+
guards: [
|
|
1918
|
+
{ ns: BRAND_NS.asset, id, exists: false },
|
|
1919
|
+
{
|
|
1920
|
+
ns: BRAND_NS.book,
|
|
1921
|
+
id: book.id,
|
|
1922
|
+
exists: true,
|
|
1923
|
+
equals: {
|
|
1924
|
+
version: book.version,
|
|
1925
|
+
activationRevision: book.activationRevision,
|
|
1926
|
+
memberIds: book.memberIds
|
|
1927
|
+
}
|
|
1928
|
+
}
|
|
1929
|
+
],
|
|
1930
|
+
asUser: ctx.actor.id,
|
|
1931
|
+
...ctx.actor.email ? { asEmail: ctx.actor.email } : {},
|
|
1932
|
+
asPrincipalKind: "human"
|
|
1336
1933
|
});
|
|
1337
|
-
|
|
1934
|
+
} catch (error) {
|
|
1935
|
+
await ctx.db.storage.delete(record6.path);
|
|
1936
|
+
throw error;
|
|
1338
1937
|
}
|
|
1938
|
+
return json(await linkedAsset(ctx, book, id), 201);
|
|
1939
|
+
}
|
|
1940
|
+
|
|
1941
|
+
// src/routes/assets.ts
|
|
1942
|
+
async function handleAssetsRoot(ctx, req, bookId) {
|
|
1339
1943
|
if (req.method === "POST") return uploadAsset(ctx, req, bookId);
|
|
1340
|
-
return methodNotAllowed();
|
|
1944
|
+
if (req.method !== "GET") return methodNotAllowed();
|
|
1945
|
+
const book = await loadMemberBook(ctx.db, bookId, ctx.actor.id);
|
|
1946
|
+
const result = await ctx.db.query({
|
|
1947
|
+
[BRAND_NS.asset]: {
|
|
1948
|
+
$: { where: { bookId: book.id, status: "live" }, order: { createdAt: "asc" } },
|
|
1949
|
+
book: {}
|
|
1950
|
+
}
|
|
1951
|
+
});
|
|
1952
|
+
const assets = (result[BRAND_NS.asset] ?? []).filter((value) => {
|
|
1953
|
+
const row = value;
|
|
1954
|
+
return row.bookId === book.id && Array.isArray(row.book) && row.book.length === 1 && row.book[0]?.id === book.id;
|
|
1955
|
+
});
|
|
1956
|
+
return json({ assets });
|
|
1957
|
+
}
|
|
1958
|
+
async function handleAssetContent(ctx, req, bookId, assetId) {
|
|
1959
|
+
if (req.method !== "GET") return methodNotAllowed();
|
|
1960
|
+
const book = await loadMemberBook(ctx.db, bookId, ctx.actor.id);
|
|
1961
|
+
const asset = await linkedAsset(ctx, book, assetId);
|
|
1962
|
+
const expiresInSeconds = 300;
|
|
1963
|
+
const url = await ctx.db.storage.sign(asset.path, expiresInSeconds);
|
|
1964
|
+
return json(
|
|
1965
|
+
{ assetId, url, expiresInSeconds },
|
|
1966
|
+
200,
|
|
1967
|
+
{ "cache-control": "private, no-store", "x-content-type-options": "nosniff" }
|
|
1968
|
+
);
|
|
1341
1969
|
}
|
|
1342
1970
|
async function handleAssetItem(ctx, req, bookId, assetId) {
|
|
1343
1971
|
if (req.method !== "DELETE") return methodNotAllowed();
|
|
1344
1972
|
const book = await loadMemberBook(ctx.db, bookId, ctx.actor.id);
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1973
|
+
let asset = await linkedAsset(ctx, book, assetId, true);
|
|
1974
|
+
const authority = await requireRouteAuthority(
|
|
1975
|
+
ctx,
|
|
1976
|
+
req,
|
|
1977
|
+
"brand.edit",
|
|
1978
|
+
"destructive",
|
|
1979
|
+
book.id
|
|
1980
|
+
);
|
|
1981
|
+
const mutationBase = `brand:asset-delete:v1:${book.id}:${asset.id}`;
|
|
1982
|
+
if (asset.status === "live") {
|
|
1983
|
+
const deletedAt = ctx.now();
|
|
1984
|
+
try {
|
|
1985
|
+
await ctx.db.transact(beginAssetDeleteOps(
|
|
1986
|
+
asset.id,
|
|
1987
|
+
deletedAt,
|
|
1988
|
+
ctx.actor.id,
|
|
1989
|
+
authority.authorityRef
|
|
1990
|
+
), {
|
|
1991
|
+
mutationId: `${mutationBase}:begin`,
|
|
1992
|
+
guards: [
|
|
1993
|
+
{
|
|
1994
|
+
ns: BRAND_NS.asset,
|
|
1995
|
+
id: asset.id,
|
|
1996
|
+
exists: true,
|
|
1997
|
+
equals: {
|
|
1998
|
+
bookId: book.id,
|
|
1999
|
+
path: asset.path,
|
|
2000
|
+
storageObjectId: asset.storageObjectId,
|
|
2001
|
+
contentDigest: asset.contentDigest,
|
|
2002
|
+
status: "live"
|
|
2003
|
+
}
|
|
2004
|
+
},
|
|
2005
|
+
{
|
|
2006
|
+
ns: BRAND_NS.book,
|
|
2007
|
+
id: book.id,
|
|
2008
|
+
exists: true,
|
|
2009
|
+
equals: { version: book.version, memberIds: book.memberIds }
|
|
2010
|
+
}
|
|
2011
|
+
],
|
|
2012
|
+
asUser: ctx.actor.id,
|
|
2013
|
+
...ctx.actor.email ? { asEmail: ctx.actor.email } : {},
|
|
2014
|
+
asPrincipalKind: "human"
|
|
2015
|
+
});
|
|
2016
|
+
} catch (error) {
|
|
2017
|
+
if (typeof error === "object" && error !== null && error.code === "transact_guard_failed") throw new BrandConflictError("asset changed before deletion");
|
|
2018
|
+
throw error;
|
|
2019
|
+
}
|
|
2020
|
+
asset = { ...asset, status: "deleting", deletedAt };
|
|
2021
|
+
}
|
|
1350
2022
|
await ctx.db.storage.delete(asset.path);
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
2023
|
+
await ctx.db.transact(finishAssetDeleteOps(asset.id), {
|
|
2024
|
+
mutationId: `${mutationBase}:finish`,
|
|
2025
|
+
guards: [{
|
|
2026
|
+
ns: BRAND_NS.asset,
|
|
2027
|
+
id: asset.id,
|
|
2028
|
+
exists: true,
|
|
2029
|
+
equals: {
|
|
2030
|
+
bookId: book.id,
|
|
2031
|
+
path: asset.path,
|
|
2032
|
+
storageObjectId: asset.storageObjectId,
|
|
2033
|
+
contentDigest: asset.contentDigest,
|
|
2034
|
+
status: "deleting",
|
|
2035
|
+
deletedBy: asset.deletedBy ?? ctx.actor.id,
|
|
2036
|
+
deletedAuthorityRef: asset.deletedAuthorityRef ?? authority.authorityRef
|
|
2037
|
+
}
|
|
2038
|
+
}],
|
|
2039
|
+
asUser: ctx.actor.id,
|
|
2040
|
+
...ctx.actor.email ? { asEmail: ctx.actor.email } : {},
|
|
2041
|
+
asPrincipalKind: "human"
|
|
2042
|
+
});
|
|
2043
|
+
return json({ id: asset.id, status: "deleted", deletedAt: asset.deletedAt });
|
|
1354
2044
|
}
|
|
1355
2045
|
|
|
1356
2046
|
// src/routes/books.ts
|
|
@@ -1363,73 +2053,1036 @@ async function handleBooksRoot(ctx, req) {
|
|
|
1363
2053
|
return json({ books });
|
|
1364
2054
|
}
|
|
1365
2055
|
if (req.method === "POST") {
|
|
2056
|
+
if (ctx.actor.kind !== "human")
|
|
2057
|
+
throw new BrandForbiddenError("only a human can create a brand book");
|
|
1366
2058
|
const body = await readJson(req);
|
|
1367
2059
|
const id = ctx.newId();
|
|
2060
|
+
const authority = await requireRouteAuthority(
|
|
2061
|
+
ctx,
|
|
2062
|
+
req,
|
|
2063
|
+
"brand.edit",
|
|
2064
|
+
"internal"
|
|
2065
|
+
);
|
|
1368
2066
|
const ops = createBookOps({
|
|
1369
2067
|
id,
|
|
1370
2068
|
slug: str(body, "slug"),
|
|
1371
2069
|
name: str(body, "name"),
|
|
1372
2070
|
ownerId: ctx.actor.id,
|
|
2071
|
+
createdAuthorityRef: authority.authorityRef,
|
|
1373
2072
|
memberIds: optStrArray(body, "memberIds") ?? [],
|
|
1374
2073
|
channelId: optStr(body, "channelId"),
|
|
1375
2074
|
now: ctx.now()
|
|
1376
2075
|
});
|
|
1377
|
-
await ctx.db.transact(ops, {
|
|
2076
|
+
await ctx.db.transact(ops, {
|
|
2077
|
+
mutationId: id,
|
|
2078
|
+
guards: [{ ns: BRAND_NS.book, id, exists: false }],
|
|
2079
|
+
asUser: ctx.actor.id,
|
|
2080
|
+
...ctx.actor.email ? { asEmail: ctx.actor.email } : {},
|
|
2081
|
+
asPrincipalKind: "human"
|
|
2082
|
+
});
|
|
1378
2083
|
return json(await loadBook(ctx.db, id), 201);
|
|
1379
2084
|
}
|
|
1380
2085
|
return methodNotAllowed();
|
|
1381
2086
|
}
|
|
2087
|
+
var sameStrings = (left, right) => left.length === right.length && left.every((value, index) => value === right[index]);
|
|
2088
|
+
var guardFailure = (error) => typeof error === "object" && error !== null && error.code === "transact_guard_failed";
|
|
2089
|
+
async function handleBookMembers(ctx, req, bookId) {
|
|
2090
|
+
if (req.method !== "PATCH") return methodNotAllowed();
|
|
2091
|
+
const book = await loadMemberBook(ctx.db, bookId, ctx.actor.id);
|
|
2092
|
+
if (ctx.actor.kind !== "human")
|
|
2093
|
+
throw new BrandForbiddenError("only a human app owner can change members");
|
|
2094
|
+
const body = await readJson(req);
|
|
2095
|
+
const rawMembers = optStrArray(body, "memberIds");
|
|
2096
|
+
const rawExpected = optStrArray(body, "expectedMemberIds");
|
|
2097
|
+
if (!rawMembers || !rawExpected)
|
|
2098
|
+
throw new BrandInputError('"memberIds" and "expectedMemberIds" are required string arrays');
|
|
2099
|
+
const memberIds = Array.from(/* @__PURE__ */ new Set([
|
|
2100
|
+
book.ownerId,
|
|
2101
|
+
...rawMembers.map((id, index) => capString(id, `memberIds[${index}]`, 160))
|
|
2102
|
+
]));
|
|
2103
|
+
if (memberIds.length > 100)
|
|
2104
|
+
throw new BrandInputError("a brand book supports at most 100 members");
|
|
2105
|
+
const expectedMemberIds = rawExpected.map((id, index) => capString(id, `expectedMemberIds[${index}]`, 160));
|
|
2106
|
+
if (!sameStrings(book.memberIds, expectedMemberIds) && !sameStrings(book.memberIds, memberIds))
|
|
2107
|
+
throw new BrandConflictError("brand book membership changed since review");
|
|
2108
|
+
const mutationId = capString(str(body, "mutationId"), "mutationId", 200);
|
|
2109
|
+
const mutationKey = `brand:book-members:v1:${bookId}:${mutationId}`;
|
|
2110
|
+
const authority = await requireRouteAuthority(
|
|
2111
|
+
ctx,
|
|
2112
|
+
req,
|
|
2113
|
+
"app.manage",
|
|
2114
|
+
"internal",
|
|
2115
|
+
bookId
|
|
2116
|
+
);
|
|
2117
|
+
try {
|
|
2118
|
+
const tx = await ctx.db.transact(
|
|
2119
|
+
[{
|
|
2120
|
+
t: "update",
|
|
2121
|
+
ns: BRAND_NS.book,
|
|
2122
|
+
id: bookId,
|
|
2123
|
+
attrs: {
|
|
2124
|
+
memberIds,
|
|
2125
|
+
version: book.version + 1,
|
|
2126
|
+
updatedAt: ctx.now(),
|
|
2127
|
+
rosterAuthorityRef: authority.authorityRef
|
|
2128
|
+
}
|
|
2129
|
+
}],
|
|
2130
|
+
{
|
|
2131
|
+
mutationId: mutationKey,
|
|
2132
|
+
guards: [{
|
|
2133
|
+
ns: BRAND_NS.book,
|
|
2134
|
+
id: bookId,
|
|
2135
|
+
exists: true,
|
|
2136
|
+
equals: { memberIds: expectedMemberIds, version: book.version }
|
|
2137
|
+
}],
|
|
2138
|
+
asUser: ctx.actor.id,
|
|
2139
|
+
...ctx.actor.email ? { asEmail: ctx.actor.email } : {},
|
|
2140
|
+
asPrincipalKind: "human"
|
|
2141
|
+
}
|
|
2142
|
+
);
|
|
2143
|
+
return json({ bookId, memberIds, duplicate: tx.duplicate === true });
|
|
2144
|
+
} catch (error) {
|
|
2145
|
+
if (guardFailure(error))
|
|
2146
|
+
throw new BrandConflictError("brand book membership changed since review");
|
|
2147
|
+
throw error;
|
|
2148
|
+
}
|
|
2149
|
+
}
|
|
1382
2150
|
async function handleBookItem(ctx, req, bookId) {
|
|
1383
2151
|
if (req.method !== "GET") return methodNotAllowed();
|
|
1384
2152
|
return json(await loadMemberBook(ctx.db, bookId, ctx.actor.id));
|
|
1385
2153
|
}
|
|
1386
2154
|
|
|
2155
|
+
// src/routes/proposal-input.ts
|
|
2156
|
+
var SNAPSHOT_KEYS = [
|
|
2157
|
+
"audience",
|
|
2158
|
+
"bookId",
|
|
2159
|
+
"createdAt",
|
|
2160
|
+
"createdAuthorityRef",
|
|
2161
|
+
"createdBy",
|
|
2162
|
+
"id",
|
|
2163
|
+
"kind",
|
|
2164
|
+
"payload",
|
|
2165
|
+
"provenance",
|
|
2166
|
+
"rationale",
|
|
2167
|
+
"reviewDigest",
|
|
2168
|
+
"status"
|
|
2169
|
+
];
|
|
2170
|
+
var record3 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
2171
|
+
function parseProposalResolutionRequest(body, bookId, proposalId) {
|
|
2172
|
+
const allowed = /* @__PURE__ */ new Set([
|
|
2173
|
+
"mutationId",
|
|
2174
|
+
"resolution",
|
|
2175
|
+
"reviewedProposal",
|
|
2176
|
+
"note"
|
|
2177
|
+
]);
|
|
2178
|
+
if (Object.keys(body).some((key) => !allowed.has(key)))
|
|
2179
|
+
throw new BrandInputError("proposal resolution body has unknown fields");
|
|
2180
|
+
const mutationId = capString(str(body, "mutationId"), "mutationId", 200);
|
|
2181
|
+
const rawResolution = str(body, "resolution");
|
|
2182
|
+
if (rawResolution !== "accepted" && rawResolution !== "rejected")
|
|
2183
|
+
throw new BrandInputError('"resolution" must be "accepted" or "rejected"');
|
|
2184
|
+
const rawNote = optStr(body, "note");
|
|
2185
|
+
const resolutionNote = rawNote?.trim() ? capString(rawNote, "note", 1e3) : void 0;
|
|
2186
|
+
return {
|
|
2187
|
+
mutationId,
|
|
2188
|
+
resolution: rawResolution,
|
|
2189
|
+
reviewed: parseReviewedProposal(body.reviewedProposal, bookId, proposalId),
|
|
2190
|
+
...resolutionNote ? { resolutionNote } : {}
|
|
2191
|
+
};
|
|
2192
|
+
}
|
|
2193
|
+
function proposalReviewSnapshot(proposal) {
|
|
2194
|
+
if (proposal.status !== "open")
|
|
2195
|
+
throw new BrandConflictError(`proposal ${proposal.id} is ${proposal.status}, not open`);
|
|
2196
|
+
return {
|
|
2197
|
+
id: proposal.id,
|
|
2198
|
+
bookId: proposal.bookId,
|
|
2199
|
+
kind: proposal.kind,
|
|
2200
|
+
status: "open",
|
|
2201
|
+
payload: proposal.payload,
|
|
2202
|
+
rationale: proposal.rationale,
|
|
2203
|
+
provenance: proposal.provenance,
|
|
2204
|
+
reviewDigest: proposal.reviewDigest,
|
|
2205
|
+
audience: proposal.audience,
|
|
2206
|
+
createdBy: proposal.createdBy,
|
|
2207
|
+
createdAuthorityRef: proposal.createdAuthorityRef,
|
|
2208
|
+
createdAt: proposal.createdAt
|
|
2209
|
+
};
|
|
2210
|
+
}
|
|
2211
|
+
function parseReviewedProposal(value, bookId, proposalId) {
|
|
2212
|
+
if (!isBoundedBrandJson(value, { maxDepth: 12, maxNodes: 1500, maxBytes: 32 * 1024 }))
|
|
2213
|
+
throw new BrandInputError('"reviewedProposal" is too deeply nested, complex, or large');
|
|
2214
|
+
if (!record3(value)) throw new BrandInputError('"reviewedProposal" must be an object');
|
|
2215
|
+
const keys = Object.keys(value).sort();
|
|
2216
|
+
if (keys.length !== SNAPSHOT_KEYS.length || !SNAPSHOT_KEYS.every((key, index) => key === keys[index])) throw new BrandInputError('"reviewedProposal" has an invalid shape');
|
|
2217
|
+
if (value.id !== proposalId || value.bookId !== bookId || value.status !== "open")
|
|
2218
|
+
throw new BrandInputError('"reviewedProposal" must identify this open proposal');
|
|
2219
|
+
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');
|
|
2220
|
+
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(
|
|
2221
|
+
value.provenance.sourceAsset.analysisDigest
|
|
2222
|
+
)) || 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');
|
|
2223
|
+
return value;
|
|
2224
|
+
}
|
|
2225
|
+
var proposalGuardEquals = (snapshot) => ({ ...snapshot });
|
|
2226
|
+
|
|
2227
|
+
// src/routes/proposal-effects.ts
|
|
2228
|
+
async function tokenSnapshot(swatches, typography, input, paletteId, paletteReceiptId, paletteActionDigest, typographyReceiptId, typographyActionDigest) {
|
|
2229
|
+
const compiled = compileBrandTokens({ swatches, ...typography ? { typography } : {} });
|
|
2230
|
+
const sources = {
|
|
2231
|
+
paletteId,
|
|
2232
|
+
paletteReceiptId,
|
|
2233
|
+
paletteActionDigest,
|
|
2234
|
+
typographyReceiptId: typographyReceiptId ?? null,
|
|
2235
|
+
typographyActionDigest: typographyActionDigest ?? null
|
|
2236
|
+
};
|
|
2237
|
+
return {
|
|
2238
|
+
light: compiled.light,
|
|
2239
|
+
dark: compiled.dark,
|
|
2240
|
+
warnings: compiled.warnings,
|
|
2241
|
+
compiledAt: input.now,
|
|
2242
|
+
paletteId,
|
|
2243
|
+
paletteReceiptId,
|
|
2244
|
+
paletteActionDigest,
|
|
2245
|
+
...typographyReceiptId ? { typographyReceiptId } : {},
|
|
2246
|
+
...typographyActionDigest ? { typographyActionDigest } : {},
|
|
2247
|
+
sourceDigest: await brandJsonDigest(sources)
|
|
2248
|
+
};
|
|
2249
|
+
}
|
|
2250
|
+
function stampProposal(ops, input) {
|
|
2251
|
+
const op = ops.find(
|
|
2252
|
+
(candidate) => candidate.t === "update" && candidate.ns === BRAND_NS.proposal && candidate.id === input.proposal.id
|
|
2253
|
+
);
|
|
2254
|
+
if (!op || op.t !== "update") throw new Error("proposal resolution op missing");
|
|
2255
|
+
Object.assign(op.attrs, {
|
|
2256
|
+
approvedBy: input.actorId,
|
|
2257
|
+
appliedBy: input.actorId,
|
|
2258
|
+
resolutionReceiptId: input.receiptId,
|
|
2259
|
+
resolutionActionDigest: input.actionDigest
|
|
2260
|
+
});
|
|
2261
|
+
}
|
|
2262
|
+
async function proposalDecisionOps(input) {
|
|
2263
|
+
const common = {
|
|
2264
|
+
proposal: input.proposal,
|
|
2265
|
+
resolvedBy: input.actorId,
|
|
2266
|
+
now: input.now,
|
|
2267
|
+
resolutionNote: input.resolutionNote
|
|
2268
|
+
};
|
|
2269
|
+
if (input.resolution === "rejected") {
|
|
2270
|
+
const ops2 = rejectProposalOps(common);
|
|
2271
|
+
stampProposal(ops2, input);
|
|
2272
|
+
return ops2;
|
|
2273
|
+
}
|
|
2274
|
+
let ops;
|
|
2275
|
+
if (input.proposal.kind === "palette") {
|
|
2276
|
+
if (!input.paletteId) throw new Error("accepted palette id missing");
|
|
2277
|
+
ops = acceptProposalOps({
|
|
2278
|
+
...common,
|
|
2279
|
+
paletteId: input.paletteId,
|
|
2280
|
+
approvalReceiptId: input.receiptId,
|
|
2281
|
+
approvalActionDigest: input.actionDigest
|
|
2282
|
+
});
|
|
2283
|
+
const swatches = assertSwatches(input.proposal.payload.swatches);
|
|
2284
|
+
const tokens = await tokenSnapshot(
|
|
2285
|
+
swatches,
|
|
2286
|
+
input.approvedTypography?.content,
|
|
2287
|
+
input,
|
|
2288
|
+
input.paletteId,
|
|
2289
|
+
input.receiptId,
|
|
2290
|
+
input.actionDigest,
|
|
2291
|
+
input.approvedTypography?.approvalReceiptId,
|
|
2292
|
+
input.approvedTypography?.approvalActionDigest
|
|
2293
|
+
);
|
|
2294
|
+
const bookOp = ops.find(
|
|
2295
|
+
(op) => op.t === "update" && op.ns === BRAND_NS.book && op.id === input.book.id
|
|
2296
|
+
);
|
|
2297
|
+
if (!bookOp || bookOp.t !== "update") throw new Error("book activation op missing");
|
|
2298
|
+
Object.assign(bookOp.attrs, {
|
|
2299
|
+
version: input.book.version + 1,
|
|
2300
|
+
activationRevision: input.book.activationRevision + 1,
|
|
2301
|
+
tokens
|
|
2302
|
+
});
|
|
2303
|
+
if (input.priorPalette && input.priorPalette.id !== input.paletteId) {
|
|
2304
|
+
ops.unshift({
|
|
2305
|
+
t: "update",
|
|
2306
|
+
ns: BRAND_NS.palette,
|
|
2307
|
+
id: input.priorPalette.id,
|
|
2308
|
+
attrs: { status: "archived", updatedAt: input.now }
|
|
2309
|
+
});
|
|
2310
|
+
}
|
|
2311
|
+
} else {
|
|
2312
|
+
const content = input.proposal.payload.content;
|
|
2313
|
+
if (typeof content !== "object" || content === null || Array.isArray(content))
|
|
2314
|
+
throw new BrandInputError("proposal payload.content must be an object");
|
|
2315
|
+
ops = [
|
|
2316
|
+
...upsertSectionOps({
|
|
2317
|
+
bookId: input.book.id,
|
|
2318
|
+
kind: input.proposal.kind,
|
|
2319
|
+
content,
|
|
2320
|
+
status: "approved",
|
|
2321
|
+
audience: input.book.memberIds,
|
|
2322
|
+
updatedBy: input.actorId,
|
|
2323
|
+
approvalReceiptId: input.receiptId,
|
|
2324
|
+
approvalActionDigest: input.actionDigest,
|
|
2325
|
+
now: input.now
|
|
2326
|
+
}),
|
|
2327
|
+
{
|
|
2328
|
+
t: "update",
|
|
2329
|
+
ns: BRAND_NS.book,
|
|
2330
|
+
id: input.book.id,
|
|
2331
|
+
attrs: {
|
|
2332
|
+
activationRevision: input.book.activationRevision + 1,
|
|
2333
|
+
version: input.book.version + 1,
|
|
2334
|
+
updatedAt: input.now
|
|
2335
|
+
}
|
|
2336
|
+
},
|
|
2337
|
+
{
|
|
2338
|
+
t: "update",
|
|
2339
|
+
ns: BRAND_NS.proposal,
|
|
2340
|
+
id: input.proposal.id,
|
|
2341
|
+
attrs: {
|
|
2342
|
+
status: "accepted",
|
|
2343
|
+
resolvedBy: input.actorId,
|
|
2344
|
+
resolvedAt: input.now,
|
|
2345
|
+
...input.resolutionNote ? { resolutionNote: input.resolutionNote } : {}
|
|
2346
|
+
}
|
|
2347
|
+
}
|
|
2348
|
+
];
|
|
2349
|
+
if (input.proposal.kind === "typography" && input.priorPalette) {
|
|
2350
|
+
const bookOp = ops.find(
|
|
2351
|
+
(op) => op.t === "update" && op.ns === BRAND_NS.book && op.id === input.book.id
|
|
2352
|
+
);
|
|
2353
|
+
if (bookOp?.t === "update") {
|
|
2354
|
+
if (!input.priorPalette.approvalReceiptId || !input.priorPalette.approvalActionDigest)
|
|
2355
|
+
throw new BrandInputError("active palette is missing approval provenance");
|
|
2356
|
+
bookOp.attrs.tokens = await tokenSnapshot(
|
|
2357
|
+
input.priorPalette.swatches,
|
|
2358
|
+
content,
|
|
2359
|
+
input,
|
|
2360
|
+
input.priorPalette.id,
|
|
2361
|
+
input.priorPalette.approvalReceiptId,
|
|
2362
|
+
input.priorPalette.approvalActionDigest,
|
|
2363
|
+
input.receiptId,
|
|
2364
|
+
input.actionDigest
|
|
2365
|
+
);
|
|
2366
|
+
}
|
|
2367
|
+
}
|
|
2368
|
+
}
|
|
2369
|
+
stampProposal(ops, input);
|
|
2370
|
+
return ops;
|
|
2371
|
+
}
|
|
2372
|
+
|
|
2373
|
+
// src/routes/proposal-resolution-receipts.ts
|
|
2374
|
+
async function proposalResolutionMutationKey(bookId, proposalId, mutationId) {
|
|
2375
|
+
const digest = await brandJsonDigest({
|
|
2376
|
+
version: 3,
|
|
2377
|
+
bookId,
|
|
2378
|
+
proposalId,
|
|
2379
|
+
mutationId
|
|
2380
|
+
});
|
|
2381
|
+
return `brand:proposal-resolution:v3:${digest.slice("sha256:".length)}`;
|
|
2382
|
+
}
|
|
2383
|
+
async function proposalReceiptByMutation(ctx, mutationKey) {
|
|
2384
|
+
const result = await ctx.db.query({
|
|
2385
|
+
[BRAND_NS.approvalReceipt]: {
|
|
2386
|
+
$: { where: { mutationKey } },
|
|
2387
|
+
book: {}
|
|
2388
|
+
}
|
|
2389
|
+
});
|
|
2390
|
+
const row = (result[BRAND_NS.approvalReceipt] ?? [])[0];
|
|
2391
|
+
if (!row || !Array.isArray(row.book) || row.book.length !== 1 || row.book[0]?.id !== row.bookId) return void 0;
|
|
2392
|
+
const { book: _book, ...receipt2 } = row;
|
|
2393
|
+
return receipt2;
|
|
2394
|
+
}
|
|
2395
|
+
async function consumeProposalAuthority(ctx, req, bookId, proposalId, actionDigest, mutationKey) {
|
|
2396
|
+
const authority = await ctx.consumeHumanExact({
|
|
2397
|
+
req,
|
|
2398
|
+
actor: ctx.actor,
|
|
2399
|
+
appId: ctx.appId,
|
|
2400
|
+
capability: "brand.proposal.resolve",
|
|
2401
|
+
projectCapability: "brand.approve",
|
|
2402
|
+
effect: "internal",
|
|
2403
|
+
resource: { bookId, proposalId },
|
|
2404
|
+
actionDigest,
|
|
2405
|
+
consumptionIdempotencyKey: mutationKey
|
|
2406
|
+
});
|
|
2407
|
+
const resourceDigest = await brandJsonDigest({ bookId, proposalId });
|
|
2408
|
+
if (!isBrandHumanAuthorityConsumption(authority) || authority.actorPrincipalId !== ctx.actor.id || authority.appId !== ctx.appId || authority.actionDigest !== actionDigest || authority.resourceDigest !== resourceDigest || authority.consumptionIdempotencyKey !== mutationKey) throw new BrandForbiddenError(
|
|
2409
|
+
"a verified human-exact brand approval is required"
|
|
2410
|
+
);
|
|
2411
|
+
return authority;
|
|
2412
|
+
}
|
|
2413
|
+
function sameProposalResolutionRequest(receipt2, reviewed, resolution, note, paletteId) {
|
|
2414
|
+
return receipt2.resolution === resolution && (receipt2.resolutionNote ?? null) === (note ?? null) && (receipt2.paletteId ?? null) === (paletteId ?? null) && canonicalBrandJson(receipt2.reviewedProposal) === canonicalBrandJson(reviewed);
|
|
2415
|
+
}
|
|
2416
|
+
|
|
2417
|
+
// src/routes/proposal-dependencies.ts
|
|
2418
|
+
async function receipt(ctx, id, actionDigest, expected) {
|
|
2419
|
+
const result = await ctx.db.query({
|
|
2420
|
+
[BRAND_NS.approvalReceipt]: {
|
|
2421
|
+
$: { where: { id } },
|
|
2422
|
+
book: {}
|
|
2423
|
+
}
|
|
2424
|
+
});
|
|
2425
|
+
const row = (result[BRAND_NS.approvalReceipt] ?? [])[0];
|
|
2426
|
+
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`);
|
|
2427
|
+
const { book: _book, ...unhydrated } = row;
|
|
2428
|
+
if (!await verifyBrandApprovalReceipt(unhydrated))
|
|
2429
|
+
throw new BrandConflictError(`approval receipt ${id} is invalid`);
|
|
2430
|
+
return unhydrated;
|
|
2431
|
+
}
|
|
2432
|
+
async function activePalette(ctx, book) {
|
|
2433
|
+
if (!book.activePaletteId) return {};
|
|
2434
|
+
const result = await ctx.db.query({
|
|
2435
|
+
[BRAND_NS.palette]: {
|
|
2436
|
+
$: { where: { id: book.activePaletteId } },
|
|
2437
|
+
book: {}
|
|
2438
|
+
}
|
|
2439
|
+
});
|
|
2440
|
+
const palette = (result[BRAND_NS.palette] ?? [])[0];
|
|
2441
|
+
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(
|
|
2442
|
+
"active palette is missing valid approval provenance"
|
|
2443
|
+
);
|
|
2444
|
+
const approval = await receipt(
|
|
2445
|
+
ctx,
|
|
2446
|
+
palette.approvalReceiptId,
|
|
2447
|
+
palette.approvalActionDigest,
|
|
2448
|
+
{
|
|
2449
|
+
bookId: book.id,
|
|
2450
|
+
kind: "palette",
|
|
2451
|
+
paletteId: palette.id,
|
|
2452
|
+
proposalId: palette.proposalId
|
|
2453
|
+
}
|
|
2454
|
+
);
|
|
2455
|
+
let expectedName;
|
|
2456
|
+
let expectedSwatches;
|
|
2457
|
+
let expectedSeed;
|
|
2458
|
+
try {
|
|
2459
|
+
expectedName = capString(
|
|
2460
|
+
approval.reviewedProposal.payload.name,
|
|
2461
|
+
"reviewedProposal.payload.name",
|
|
2462
|
+
120
|
|
2463
|
+
);
|
|
2464
|
+
expectedSwatches = assertSwatches(
|
|
2465
|
+
approval.reviewedProposal.payload.swatches
|
|
2466
|
+
);
|
|
2467
|
+
expectedSeed = approval.reviewedProposal.payload.seedHex === void 0 ? void 0 : assertHex(
|
|
2468
|
+
approval.reviewedProposal.payload.seedHex,
|
|
2469
|
+
"reviewedProposal.payload.seedHex"
|
|
2470
|
+
);
|
|
2471
|
+
} catch {
|
|
2472
|
+
throw new BrandConflictError("active palette approval payload is invalid");
|
|
2473
|
+
}
|
|
2474
|
+
const expectedSource = approval.reviewedProposal.provenance.sourceAsset ? "extracted" : expectedSeed ? "derived" : "manual";
|
|
2475
|
+
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(
|
|
2476
|
+
"active palette does not match its approved proposal"
|
|
2477
|
+
);
|
|
2478
|
+
return { palette, receipt: approval };
|
|
2479
|
+
}
|
|
2480
|
+
async function approvedTypography(ctx, book) {
|
|
2481
|
+
const key = `${book.id}:typography`;
|
|
2482
|
+
const result = await ctx.db.query({
|
|
2483
|
+
[BRAND_NS.section]: {
|
|
2484
|
+
$: { where: { key } },
|
|
2485
|
+
book: {}
|
|
2486
|
+
}
|
|
2487
|
+
});
|
|
2488
|
+
const section = (result[BRAND_NS.section] ?? [])[0];
|
|
2489
|
+
if (!section || section.status !== "approved") return {};
|
|
2490
|
+
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(
|
|
2491
|
+
"approved typography is missing valid approval provenance"
|
|
2492
|
+
);
|
|
2493
|
+
assertSectionContent("typography", section.content);
|
|
2494
|
+
return {
|
|
2495
|
+
section,
|
|
2496
|
+
receipt: await receipt(
|
|
2497
|
+
ctx,
|
|
2498
|
+
section.approvalReceiptId,
|
|
2499
|
+
section.approvalActionDigest,
|
|
2500
|
+
{ bookId: book.id, kind: "typography", content: section.content }
|
|
2501
|
+
)
|
|
2502
|
+
};
|
|
2503
|
+
}
|
|
2504
|
+
var receiptGuard = (value) => ({
|
|
2505
|
+
ns: BRAND_NS.approvalReceipt,
|
|
2506
|
+
id: value.id,
|
|
2507
|
+
exists: true,
|
|
2508
|
+
equals: {
|
|
2509
|
+
actionDigest: value.actionDigest,
|
|
2510
|
+
receiptDigest: value.receiptDigest,
|
|
2511
|
+
resolution: "accepted"
|
|
2512
|
+
}
|
|
2513
|
+
});
|
|
2514
|
+
|
|
2515
|
+
// src/routes/proposal-state.ts
|
|
2516
|
+
async function proposalDecisionState(ctx, req, book, proposal, resolution, receiptId, paletteId) {
|
|
2517
|
+
const needsPalette = resolution === "accepted" && (proposal.kind === "palette" || proposal.kind === "typography");
|
|
2518
|
+
const paletteDep = needsPalette ? await activePalette(ctx, book) : {};
|
|
2519
|
+
const typeDep = resolution === "accepted" && proposal.kind === "palette" ? await approvedTypography(ctx, book) : {};
|
|
2520
|
+
const dependencyGuards = [];
|
|
2521
|
+
const source = proposal.provenance.sourceAsset;
|
|
2522
|
+
let sourceAsset;
|
|
2523
|
+
if (source && resolution === "accepted") {
|
|
2524
|
+
const result = await ctx.db.query({
|
|
2525
|
+
[BRAND_NS.asset]: {
|
|
2526
|
+
$: { where: { id: source.assetId, bookId: book.id } },
|
|
2527
|
+
book: {}
|
|
2528
|
+
}
|
|
2529
|
+
});
|
|
2530
|
+
sourceAsset = (result[BRAND_NS.asset] ?? [])[0];
|
|
2531
|
+
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(
|
|
2532
|
+
"source asset changed since the proposal was reviewed"
|
|
2533
|
+
);
|
|
2534
|
+
if (!await ctx.verifySourceAssetSnapshot({
|
|
2535
|
+
req,
|
|
2536
|
+
appId: ctx.appId,
|
|
2537
|
+
bookId: book.id,
|
|
2538
|
+
assetId: source.assetId,
|
|
2539
|
+
path: sourceAsset.path,
|
|
2540
|
+
snapshot: source
|
|
2541
|
+
})) throw new BrandConflictError(
|
|
2542
|
+
"source asset bytes changed since the proposal was reviewed"
|
|
2543
|
+
);
|
|
2544
|
+
dependencyGuards.push({
|
|
2545
|
+
ns: BRAND_NS.asset,
|
|
2546
|
+
id: sourceAsset.id,
|
|
2547
|
+
exists: true,
|
|
2548
|
+
equals: {
|
|
2549
|
+
bookId: book.id,
|
|
2550
|
+
status: "live",
|
|
2551
|
+
path: sourceAsset.path,
|
|
2552
|
+
storageObjectId: sourceAsset.storageObjectId,
|
|
2553
|
+
contentDigest: source.contentDigest,
|
|
2554
|
+
contentType: source.contentType,
|
|
2555
|
+
size: source.objectSize,
|
|
2556
|
+
analysisRevision: source.analysisRevision,
|
|
2557
|
+
...source.analysisDigest ? { analysisDigest: source.analysisDigest } : {}
|
|
2558
|
+
}
|
|
2559
|
+
});
|
|
2560
|
+
}
|
|
2561
|
+
if (paletteDep.palette && paletteDep.receipt) {
|
|
2562
|
+
dependencyGuards.push({
|
|
2563
|
+
ns: BRAND_NS.palette,
|
|
2564
|
+
id: paletteDep.palette.id,
|
|
2565
|
+
exists: true,
|
|
2566
|
+
equals: {
|
|
2567
|
+
bookId: book.id,
|
|
2568
|
+
status: "active",
|
|
2569
|
+
name: paletteDep.palette.name,
|
|
2570
|
+
swatches: paletteDep.palette.swatches,
|
|
2571
|
+
seedHex: paletteDep.palette.seedHex ?? null,
|
|
2572
|
+
source: paletteDep.palette.source,
|
|
2573
|
+
rationale: paletteDep.palette.rationale,
|
|
2574
|
+
proposalId: paletteDep.palette.proposalId,
|
|
2575
|
+
approvalReceiptId: paletteDep.palette.approvalReceiptId,
|
|
2576
|
+
approvalActionDigest: paletteDep.palette.approvalActionDigest,
|
|
2577
|
+
audience: paletteDep.palette.audience,
|
|
2578
|
+
createdAt: paletteDep.palette.createdAt,
|
|
2579
|
+
updatedAt: paletteDep.palette.updatedAt
|
|
2580
|
+
}
|
|
2581
|
+
}, receiptGuard(paletteDep.receipt));
|
|
2582
|
+
}
|
|
2583
|
+
if (typeDep.section && typeDep.receipt) {
|
|
2584
|
+
dependencyGuards.push({
|
|
2585
|
+
ns: BRAND_NS.section,
|
|
2586
|
+
id: typeDep.section.id,
|
|
2587
|
+
exists: true,
|
|
2588
|
+
equals: {
|
|
2589
|
+
key: typeDep.section.key,
|
|
2590
|
+
bookId: book.id,
|
|
2591
|
+
kind: "typography",
|
|
2592
|
+
status: "approved",
|
|
2593
|
+
content: typeDep.section.content,
|
|
2594
|
+
approvalReceiptId: typeDep.section.approvalReceiptId,
|
|
2595
|
+
approvalActionDigest: typeDep.section.approvalActionDigest,
|
|
2596
|
+
updatedAt: typeDep.section.updatedAt
|
|
2597
|
+
}
|
|
2598
|
+
}, receiptGuard(typeDep.receipt));
|
|
2599
|
+
}
|
|
2600
|
+
let approvedContent = null;
|
|
2601
|
+
let paletteResult = null;
|
|
2602
|
+
if (resolution === "accepted" && proposal.kind === "palette") {
|
|
2603
|
+
if (!paletteId) throw new BrandNotFoundError("resulting palette id");
|
|
2604
|
+
paletteResult = {
|
|
2605
|
+
id: paletteId,
|
|
2606
|
+
name: capString(proposal.payload.name, "payload.name", 120),
|
|
2607
|
+
swatches: assertSwatches(proposal.payload.swatches),
|
|
2608
|
+
seedHex: proposal.payload.seedHex ?? null,
|
|
2609
|
+
receiptId,
|
|
2610
|
+
archivePaletteId: paletteDep.palette?.id ?? null
|
|
2611
|
+
};
|
|
2612
|
+
} else if (resolution === "accepted") {
|
|
2613
|
+
approvedContent = assertSectionContent(
|
|
2614
|
+
proposal.kind,
|
|
2615
|
+
proposal.payload.content
|
|
2616
|
+
);
|
|
2617
|
+
}
|
|
2618
|
+
const tokenSources = resolution === "accepted" && (proposal.kind === "palette" || proposal.kind === "typography") ? {
|
|
2619
|
+
paletteReceiptId: proposal.kind === "palette" ? receiptId : paletteDep.palette?.approvalReceiptId ?? null,
|
|
2620
|
+
paletteActionDigest: proposal.kind === "palette" ? "this-action" : paletteDep.palette?.approvalActionDigest ?? null,
|
|
2621
|
+
typographyReceiptId: proposal.kind === "typography" ? receiptId : typeDep.section?.approvalReceiptId ?? null,
|
|
2622
|
+
typographyActionDigest: proposal.kind === "typography" ? "this-action" : typeDep.section?.approvalActionDigest ?? null
|
|
2623
|
+
} : null;
|
|
2624
|
+
const effectDescriptor = {
|
|
2625
|
+
proposalId: proposal.id,
|
|
2626
|
+
resolution,
|
|
2627
|
+
nextBookVersion: resolution === "accepted" ? book.version + 1 : null,
|
|
2628
|
+
nextActivationRevision: resolution === "accepted" ? book.activationRevision + 1 : null,
|
|
2629
|
+
resultingActivePaletteId: proposal.kind === "palette" && resolution === "accepted" ? paletteId ?? null : book.activePaletteId ?? null,
|
|
2630
|
+
palette: paletteResult,
|
|
2631
|
+
approvedSection: approvedContent ? { kind: proposal.kind, content: approvedContent, receiptId } : null,
|
|
2632
|
+
tokenSources
|
|
2633
|
+
};
|
|
2634
|
+
const binding = {
|
|
2635
|
+
version: 1,
|
|
2636
|
+
bookVersion: book.version,
|
|
2637
|
+
activationRevision: book.activationRevision,
|
|
2638
|
+
activePaletteId: book.activePaletteId ?? null,
|
|
2639
|
+
memberIdsDigest: await brandJsonDigest(book.memberIds),
|
|
2640
|
+
activePaletteDigest: paletteDep.palette ? await brandJsonDigest({
|
|
2641
|
+
id: paletteDep.palette.id,
|
|
2642
|
+
swatches: paletteDep.palette.swatches,
|
|
2643
|
+
approvalReceiptId: paletteDep.palette.approvalReceiptId,
|
|
2644
|
+
approvalActionDigest: paletteDep.palette.approvalActionDigest
|
|
2645
|
+
}) : null,
|
|
2646
|
+
typographyDigest: typeDep.section ? await brandJsonDigest({
|
|
2647
|
+
key: typeDep.section.key,
|
|
2648
|
+
content: typeDep.section.content,
|
|
2649
|
+
approvalReceiptId: typeDep.section.approvalReceiptId,
|
|
2650
|
+
approvalActionDigest: typeDep.section.approvalActionDigest
|
|
2651
|
+
}) : null,
|
|
2652
|
+
sourceAssetDigest: source ? await brandJsonDigest(source) : null,
|
|
2653
|
+
effectDigest: await brandJsonDigest(effectDescriptor)
|
|
2654
|
+
};
|
|
2655
|
+
return {
|
|
2656
|
+
...paletteDep.palette ? { priorPalette: paletteDep.palette } : {},
|
|
2657
|
+
...typeDep.section ? { approvedTypography: typeDep.section } : {},
|
|
2658
|
+
binding,
|
|
2659
|
+
effectDescriptor,
|
|
2660
|
+
dependencyGuards
|
|
2661
|
+
};
|
|
2662
|
+
}
|
|
2663
|
+
|
|
2664
|
+
// src/routes/proposals.ts
|
|
2665
|
+
var record4 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
2666
|
+
var sameStrings2 = (left, right) => left.length === right.length && left.every((value, index) => value === right[index]);
|
|
2667
|
+
var guardFailure2 = (error) => record4(error) && error.code === "transact_guard_failed";
|
|
2668
|
+
async function handleProposalResolution(ctx, req, bookId, proposalId) {
|
|
2669
|
+
if (req.method !== "POST") return methodNotAllowed();
|
|
2670
|
+
const book = await loadMemberBook(ctx.db, bookId, ctx.actor.id);
|
|
2671
|
+
if (ctx.actor.kind !== "human")
|
|
2672
|
+
throw new BrandForbiddenError("only a directly authenticated human can resolve a proposal");
|
|
2673
|
+
const body = await readJson(req);
|
|
2674
|
+
const {
|
|
2675
|
+
mutationId,
|
|
2676
|
+
resolution,
|
|
2677
|
+
reviewed,
|
|
2678
|
+
resolutionNote
|
|
2679
|
+
} = parseProposalResolutionRequest(body, bookId, proposalId);
|
|
2680
|
+
const mutationKey = await proposalResolutionMutationKey(
|
|
2681
|
+
bookId,
|
|
2682
|
+
proposalId,
|
|
2683
|
+
mutationId
|
|
2684
|
+
);
|
|
2685
|
+
const stable = (await brandJsonDigest({ mutationKey })).slice(7);
|
|
2686
|
+
const receiptId = `brand-receipt-${stable.slice(0, 32)}`;
|
|
2687
|
+
const paletteId = resolution === "accepted" && reviewed.kind === "palette" ? `brand-palette-${stable.slice(32)}` : void 0;
|
|
2688
|
+
const existing = await proposalReceiptByMutation(ctx, mutationKey);
|
|
2689
|
+
if (existing) {
|
|
2690
|
+
if (!await verifyBrandApprovalReceipt(existing) || !sameProposalResolutionRequest(
|
|
2691
|
+
existing,
|
|
2692
|
+
reviewed,
|
|
2693
|
+
resolution,
|
|
2694
|
+
resolutionNote,
|
|
2695
|
+
paletteId
|
|
2696
|
+
)) throw new BrandConflictError("mutationId was used for another decision");
|
|
2697
|
+
const replayed = await consumeProposalAuthority(
|
|
2698
|
+
ctx,
|
|
2699
|
+
req,
|
|
2700
|
+
bookId,
|
|
2701
|
+
proposalId,
|
|
2702
|
+
existing.actionDigest,
|
|
2703
|
+
mutationKey
|
|
2704
|
+
);
|
|
2705
|
+
if (canonicalBrandJson(replayed) !== canonicalBrandJson(existing.authorityConsumption)) throw new BrandConflictError("central approval receipt changed");
|
|
2706
|
+
return json({ receipt: existing, duplicate: true });
|
|
2707
|
+
}
|
|
2708
|
+
const result = await ctx.db.query({
|
|
2709
|
+
[BRAND_NS.proposal]: {
|
|
2710
|
+
$: { where: { id: proposalId, bookId } },
|
|
2711
|
+
book: {}
|
|
2712
|
+
}
|
|
2713
|
+
});
|
|
2714
|
+
const proposal = (result[BRAND_NS.proposal] ?? [])[0];
|
|
2715
|
+
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}`);
|
|
2716
|
+
const current = proposalReviewSnapshot(proposal);
|
|
2717
|
+
const { reviewDigest, ...unsignedReview } = current;
|
|
2718
|
+
if (await brandJsonDigest(unsignedReview) !== reviewDigest || canonicalBrandJson(current) !== canonicalBrandJson(reviewed)) throw new BrandConflictError("proposal changed since review");
|
|
2719
|
+
if (resolution === "accepted" && !sameStrings2(current.audience, book.memberIds))
|
|
2720
|
+
throw new BrandConflictError("book membership changed; review the proposal again");
|
|
2721
|
+
const state = await proposalDecisionState(
|
|
2722
|
+
ctx,
|
|
2723
|
+
req,
|
|
2724
|
+
book,
|
|
2725
|
+
proposal,
|
|
2726
|
+
resolution,
|
|
2727
|
+
receiptId,
|
|
2728
|
+
paletteId
|
|
2729
|
+
);
|
|
2730
|
+
const actionDigest = await brandJsonDigest({
|
|
2731
|
+
version: 1,
|
|
2732
|
+
reviewedProposal: current,
|
|
2733
|
+
resolution,
|
|
2734
|
+
resolutionNote: resolutionNote ?? null,
|
|
2735
|
+
paletteId: paletteId ?? null,
|
|
2736
|
+
decisionBinding: state.binding
|
|
2737
|
+
});
|
|
2738
|
+
const authority = await consumeProposalAuthority(
|
|
2739
|
+
ctx,
|
|
2740
|
+
req,
|
|
2741
|
+
bookId,
|
|
2742
|
+
proposalId,
|
|
2743
|
+
actionDigest,
|
|
2744
|
+
mutationKey
|
|
2745
|
+
);
|
|
2746
|
+
const receiptBase = {
|
|
2747
|
+
version: 1,
|
|
2748
|
+
id: receiptId,
|
|
2749
|
+
mutationKey,
|
|
2750
|
+
bookId,
|
|
2751
|
+
proposalId,
|
|
2752
|
+
resolution,
|
|
2753
|
+
...paletteId ? { paletteId } : {},
|
|
2754
|
+
...resolutionNote ? { resolutionNote } : {},
|
|
2755
|
+
reviewedProposal: current,
|
|
2756
|
+
actionDigest,
|
|
2757
|
+
decisionBinding: state.binding,
|
|
2758
|
+
approvedBy: ctx.actor.id,
|
|
2759
|
+
approvedByKind: "human",
|
|
2760
|
+
appliedBy: ctx.actor.id,
|
|
2761
|
+
appliedByKind: "human",
|
|
2762
|
+
authorityRef: brandAuthorityRef(authority),
|
|
2763
|
+
authorityCapability: "brand.approve",
|
|
2764
|
+
authorityConsumption: authority,
|
|
2765
|
+
createdAt: authority.consumedAt
|
|
2766
|
+
};
|
|
2767
|
+
const receipt2 = {
|
|
2768
|
+
...receiptBase,
|
|
2769
|
+
receiptDigest: await brandJsonDigest(receiptBase)
|
|
2770
|
+
};
|
|
2771
|
+
const ops = await proposalDecisionOps({
|
|
2772
|
+
proposal: { ...proposal, audience: [...book.memberIds] },
|
|
2773
|
+
book,
|
|
2774
|
+
resolution,
|
|
2775
|
+
receiptId,
|
|
2776
|
+
actionDigest,
|
|
2777
|
+
...paletteId ? { paletteId } : {},
|
|
2778
|
+
...state.priorPalette ? { priorPalette: state.priorPalette } : {},
|
|
2779
|
+
...state.approvedTypography ? { approvedTypography: state.approvedTypography } : {},
|
|
2780
|
+
actorId: ctx.actor.id,
|
|
2781
|
+
now: authority.consumedAt,
|
|
2782
|
+
resolutionNote
|
|
2783
|
+
});
|
|
2784
|
+
ops.push({
|
|
2785
|
+
t: "update",
|
|
2786
|
+
ns: BRAND_NS.approvalReceipt,
|
|
2787
|
+
id: receipt2.id,
|
|
2788
|
+
attrs: receipt2
|
|
2789
|
+
}, {
|
|
2790
|
+
t: "link",
|
|
2791
|
+
ns: BRAND_NS.approvalReceipt,
|
|
2792
|
+
id: receipt2.id,
|
|
2793
|
+
label: "book",
|
|
2794
|
+
target: bookId
|
|
2795
|
+
});
|
|
2796
|
+
const guards = [
|
|
2797
|
+
{
|
|
2798
|
+
ns: BRAND_NS.proposal,
|
|
2799
|
+
id: proposalId,
|
|
2800
|
+
exists: true,
|
|
2801
|
+
equals: proposalGuardEquals(current)
|
|
2802
|
+
},
|
|
2803
|
+
{ ns: BRAND_NS.approvalReceipt, id: receiptId, exists: false },
|
|
2804
|
+
{
|
|
2805
|
+
ns: BRAND_NS.book,
|
|
2806
|
+
id: bookId,
|
|
2807
|
+
exists: true,
|
|
2808
|
+
equals: {
|
|
2809
|
+
version: book.version,
|
|
2810
|
+
activationRevision: book.activationRevision,
|
|
2811
|
+
memberIds: book.memberIds
|
|
2812
|
+
}
|
|
2813
|
+
},
|
|
2814
|
+
...state.dependencyGuards
|
|
2815
|
+
];
|
|
2816
|
+
if (paletteId) {
|
|
2817
|
+
guards.push({ ns: BRAND_NS.palette, id: paletteId, exists: false });
|
|
2818
|
+
}
|
|
2819
|
+
try {
|
|
2820
|
+
const tx = await ctx.db.transact(ops, {
|
|
2821
|
+
mutationId: mutationKey,
|
|
2822
|
+
guards,
|
|
2823
|
+
asUser: ctx.actor.id,
|
|
2824
|
+
...ctx.actor.email ? { asEmail: ctx.actor.email } : {},
|
|
2825
|
+
asPrincipalKind: "human"
|
|
2826
|
+
});
|
|
2827
|
+
if (tx.duplicate) {
|
|
2828
|
+
const replay = await proposalReceiptByMutation(ctx, mutationKey);
|
|
2829
|
+
if (!replay || !await verifyBrandApprovalReceipt(replay) || !sameProposalResolutionRequest(
|
|
2830
|
+
replay,
|
|
2831
|
+
reviewed,
|
|
2832
|
+
resolution,
|
|
2833
|
+
resolutionNote,
|
|
2834
|
+
paletteId
|
|
2835
|
+
) || canonicalBrandJson(replay.authorityConsumption) !== canonicalBrandJson(authority))
|
|
2836
|
+
throw new BrandConflictError("proposal decision replay is invalid");
|
|
2837
|
+
return json({ receipt: replay, duplicate: true });
|
|
2838
|
+
}
|
|
2839
|
+
return json({ receipt: receipt2, duplicate: false });
|
|
2840
|
+
} catch (error) {
|
|
2841
|
+
if (guardFailure2(error)) throw new BrandReviewStateChangedError();
|
|
2842
|
+
throw error;
|
|
2843
|
+
}
|
|
2844
|
+
}
|
|
2845
|
+
|
|
1387
2846
|
// src/routes/tokens.ts
|
|
1388
|
-
var
|
|
1389
|
-
|
|
2847
|
+
var record5 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
2848
|
+
var missing = (bookId) => {
|
|
2849
|
+
throw new BrandNotFoundError(`compiled tokens for brand book ${bookId}`);
|
|
2850
|
+
};
|
|
2851
|
+
async function acceptedReceipt(db, bookId, receiptId, actionDigest) {
|
|
2852
|
+
const result = await db.query({
|
|
2853
|
+
[BRAND_NS.approvalReceipt]: {
|
|
2854
|
+
$: { where: { id: receiptId, bookId } },
|
|
2855
|
+
book: {}
|
|
2856
|
+
}
|
|
2857
|
+
});
|
|
2858
|
+
const receipt2 = (result[BRAND_NS.approvalReceipt] ?? [])[0];
|
|
2859
|
+
if (!receipt2 || receipt2.resolution !== "accepted" || receipt2.actionDigest !== actionDigest || !Array.isArray(receipt2.book) || receipt2.book.length !== 1 || receipt2.book[0]?.id !== bookId) return missing(bookId);
|
|
2860
|
+
const { book: _book, ...unhydrated } = receipt2;
|
|
2861
|
+
if (!await verifyBrandApprovalReceipt(unhydrated)) return missing(bookId);
|
|
2862
|
+
return unhydrated;
|
|
2863
|
+
}
|
|
2864
|
+
async function approvedCache(db, book) {
|
|
1390
2865
|
const cache = book.tokens;
|
|
1391
|
-
if (cache
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
2866
|
+
if (!cache || !record5(cache.light) || !record5(cache.dark) || !book.activePaletteId || cache.paletteId !== book.activePaletteId || !cache.paletteReceiptId || !cache.paletteActionDigest || !cache.sourceDigest) return missing(book.id);
|
|
2867
|
+
const paletteResult = await db.query({
|
|
2868
|
+
[BRAND_NS.palette]: {
|
|
2869
|
+
$: { where: { id: cache.paletteId, bookId: book.id } },
|
|
2870
|
+
book: {}
|
|
2871
|
+
}
|
|
2872
|
+
});
|
|
2873
|
+
const palette = (paletteResult[BRAND_NS.palette] ?? [])[0];
|
|
2874
|
+
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);
|
|
2875
|
+
await acceptedReceipt(
|
|
2876
|
+
db,
|
|
2877
|
+
book.id,
|
|
2878
|
+
cache.paletteReceiptId,
|
|
2879
|
+
cache.paletteActionDigest
|
|
2880
|
+
);
|
|
2881
|
+
const hasTypeId = cache.typographyReceiptId !== void 0;
|
|
2882
|
+
const hasTypeDigest = cache.typographyActionDigest !== void 0;
|
|
2883
|
+
if (hasTypeId !== hasTypeDigest) return missing(book.id);
|
|
2884
|
+
const sectionResult = await db.query({
|
|
2885
|
+
[BRAND_NS.section]: {
|
|
2886
|
+
$: { where: { key: `${book.id}:typography` } },
|
|
2887
|
+
book: {}
|
|
2888
|
+
}
|
|
1396
2889
|
});
|
|
1397
|
-
const
|
|
1398
|
-
if (
|
|
1399
|
-
|
|
1400
|
-
|
|
2890
|
+
const typography = (sectionResult[BRAND_NS.section] ?? [])[0];
|
|
2891
|
+
if (hasTypeId) {
|
|
2892
|
+
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);
|
|
2893
|
+
await acceptedReceipt(
|
|
2894
|
+
db,
|
|
2895
|
+
book.id,
|
|
2896
|
+
cache.typographyReceiptId,
|
|
2897
|
+
cache.typographyActionDigest
|
|
2898
|
+
);
|
|
2899
|
+
} else if (typography?.status === "approved" && (typography.approvalReceiptId || typography.approvalActionDigest)) {
|
|
2900
|
+
return missing(book.id);
|
|
2901
|
+
}
|
|
2902
|
+
const sourceDigest = await brandJsonDigest({
|
|
2903
|
+
paletteId: cache.paletteId,
|
|
2904
|
+
paletteReceiptId: cache.paletteReceiptId,
|
|
2905
|
+
paletteActionDigest: cache.paletteActionDigest,
|
|
2906
|
+
typographyReceiptId: cache.typographyReceiptId ?? null,
|
|
2907
|
+
typographyActionDigest: cache.typographyActionDigest ?? null
|
|
1401
2908
|
});
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
return { light: compiled.light, dark: compiled.dark };
|
|
2909
|
+
if (sourceDigest !== cache.sourceDigest) return missing(book.id);
|
|
2910
|
+
return cache;
|
|
1405
2911
|
}
|
|
1406
2912
|
async function handleTokens(db, req, bookId, which, actor) {
|
|
1407
2913
|
if (req.method !== "GET") return methodNotAllowed();
|
|
1408
2914
|
const book = actor === null ? await loadBook(db, bookId) : await loadMemberBook(db, bookId, actor.id);
|
|
1409
|
-
const
|
|
1410
|
-
|
|
1411
|
-
const
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
return new Response(renderTokensCss(
|
|
2915
|
+
const cache = await approvedCache(db, book);
|
|
2916
|
+
const light = cache.light;
|
|
2917
|
+
const dark = cache.dark;
|
|
2918
|
+
if (which === "tokens.json") return json({ light, dark });
|
|
2919
|
+
const etag = `"brand-tokens-${book.activationRevision}-${cache.sourceDigest.slice(7)}"`;
|
|
2920
|
+
if (req.headers.get("if-none-match")?.split(",").map((value) => value.trim()).includes(etag)) return new Response(null, { status: 304, headers: { etag } });
|
|
2921
|
+
return new Response(renderTokensCss(light, { dark }), {
|
|
1416
2922
|
status: 200,
|
|
1417
|
-
headers: {
|
|
2923
|
+
headers: {
|
|
2924
|
+
"content-type": "text/css; charset=utf-8",
|
|
2925
|
+
"cache-control": "public, max-age=0, must-revalidate",
|
|
2926
|
+
etag
|
|
2927
|
+
}
|
|
2928
|
+
});
|
|
2929
|
+
}
|
|
2930
|
+
|
|
2931
|
+
// src/routes/discussion-references.ts
|
|
2932
|
+
var capLimit = (raw) => {
|
|
2933
|
+
const parsed = Number(raw ?? 20);
|
|
2934
|
+
return Number.isInteger(parsed) ? Math.max(1, Math.min(parsed, 20)) : 20;
|
|
2935
|
+
};
|
|
2936
|
+
var targetFromQuery = (url) => {
|
|
2937
|
+
const kind = url.searchParams.get("kind");
|
|
2938
|
+
const id = url.searchParams.get("id");
|
|
2939
|
+
if (!kind && !id) return null;
|
|
2940
|
+
if (!kind || !id) return null;
|
|
2941
|
+
const value = new URL("https://brand.invalid");
|
|
2942
|
+
value.searchParams.set("odla-ref", `${kind}/${id}`);
|
|
2943
|
+
return parseBrandDiscussionReference(value);
|
|
2944
|
+
};
|
|
2945
|
+
var childNamespace = (kind) => ({
|
|
2946
|
+
"brand:asset": BRAND_NS.asset,
|
|
2947
|
+
"brand:palette": BRAND_NS.palette,
|
|
2948
|
+
"brand:proposal": BRAND_NS.proposal,
|
|
2949
|
+
"brand:receipt": BRAND_NS.approvalReceipt
|
|
2950
|
+
})[kind] ?? null;
|
|
2951
|
+
var childTarget = (kind, row) => ({
|
|
2952
|
+
kind,
|
|
2953
|
+
bookId: row.bookId,
|
|
2954
|
+
resourceId: row.id
|
|
2955
|
+
});
|
|
2956
|
+
var projection = (req, ctx, book, target, row) => {
|
|
2957
|
+
let label = book.name;
|
|
2958
|
+
let hint = `${book.status} brand book`;
|
|
2959
|
+
let summary = `${book.name} brand book`;
|
|
2960
|
+
let status = book.status;
|
|
2961
|
+
let destination = "Brand Studio \xB7 Brand book";
|
|
2962
|
+
if (target.kind === "brand:asset") {
|
|
2963
|
+
const asset = row;
|
|
2964
|
+
label = asset.title?.trim() || `${asset.kind} asset`;
|
|
2965
|
+
hint = `${book.name} \xB7 ${asset.status}`;
|
|
2966
|
+
summary = `${asset.kind} source asset in ${book.name}`;
|
|
2967
|
+
status = asset.status;
|
|
2968
|
+
destination = "Brand Studio \xB7 Conversation";
|
|
2969
|
+
} else if (target.kind === "brand:palette") {
|
|
2970
|
+
const palette = row;
|
|
2971
|
+
label = palette.name;
|
|
2972
|
+
hint = `${book.name} \xB7 ${palette.status} palette`;
|
|
2973
|
+
summary = `${palette.swatches.length}-color palette in ${book.name}`;
|
|
2974
|
+
status = palette.status;
|
|
2975
|
+
destination = "Brand Studio \xB7 Review changes";
|
|
2976
|
+
} else if (target.kind === "brand:proposal") {
|
|
2977
|
+
const proposal = row;
|
|
2978
|
+
label = `${proposal.kind} proposal`;
|
|
2979
|
+
hint = `${book.name} \xB7 ${proposal.status}`;
|
|
2980
|
+
summary = proposal.rationale;
|
|
2981
|
+
status = proposal.status;
|
|
2982
|
+
destination = "Brand Studio \xB7 Review changes";
|
|
2983
|
+
} else if (target.kind === "brand:receipt") {
|
|
2984
|
+
const receipt2 = row;
|
|
2985
|
+
label = `${receipt2.resolution} ${receipt2.reviewedProposal.kind} change`;
|
|
2986
|
+
hint = `${book.name} \xB7 approval receipt`;
|
|
2987
|
+
summary = `${receipt2.resolution} ${receipt2.reviewedProposal.kind} decision`;
|
|
2988
|
+
status = receipt2.resolution;
|
|
2989
|
+
destination = "Brand Studio \xB7 Review changes";
|
|
2990
|
+
}
|
|
2991
|
+
return {
|
|
2992
|
+
kind: target.kind,
|
|
2993
|
+
id: brandDiscussionReferenceId(target),
|
|
2994
|
+
label,
|
|
2995
|
+
hint,
|
|
2996
|
+
summary,
|
|
2997
|
+
status,
|
|
2998
|
+
destination,
|
|
2999
|
+
href: brandDiscussionReferenceHref(
|
|
3000
|
+
new URL(req.url),
|
|
3001
|
+
target,
|
|
3002
|
+
ctx.discussionBasePath
|
|
3003
|
+
)
|
|
3004
|
+
};
|
|
3005
|
+
};
|
|
3006
|
+
async function exact(ctx, req, target) {
|
|
3007
|
+
const book = await loadMemberBook(ctx.db, target.bookId, ctx.actor.id).catch(() => null);
|
|
3008
|
+
if (!book) return [];
|
|
3009
|
+
if (target.kind === "brand:book") {
|
|
3010
|
+
return [projection(req, ctx, book, target, book)];
|
|
3011
|
+
}
|
|
3012
|
+
const namespace = childNamespace(target.kind);
|
|
3013
|
+
if (!namespace || !target.resourceId) return [];
|
|
3014
|
+
const result = await ctx.db.query({
|
|
3015
|
+
[namespace]: {
|
|
3016
|
+
$: { where: { id: target.resourceId }, limit: 1 }
|
|
3017
|
+
}
|
|
3018
|
+
});
|
|
3019
|
+
const row = (result[namespace] ?? [])[0];
|
|
3020
|
+
if (!row || row.bookId !== book.id || target.kind === "brand:asset" && row.status !== "live") return [];
|
|
3021
|
+
return [projection(req, ctx, book, target, row)];
|
|
3022
|
+
}
|
|
3023
|
+
var matches = (item, needle) => !needle || `${item.label}
|
|
3024
|
+
${item.hint}
|
|
3025
|
+
${item.id}`.toLowerCase().includes(needle);
|
|
3026
|
+
async function search(ctx, req, url) {
|
|
3027
|
+
const result = await ctx.db.query({
|
|
3028
|
+
[BRAND_NS.book]: { $: { order: { updatedAt: "desc" }, limit: 100 } },
|
|
3029
|
+
[BRAND_NS.asset]: { $: { order: { createdAt: "desc" }, limit: 100 } },
|
|
3030
|
+
[BRAND_NS.palette]: { $: { order: { updatedAt: "desc" }, limit: 100 } },
|
|
3031
|
+
[BRAND_NS.proposal]: { $: { order: { createdAt: "desc" }, limit: 100 } },
|
|
3032
|
+
[BRAND_NS.approvalReceipt]: {
|
|
3033
|
+
$: { order: { createdAt: "desc" }, limit: 100 }
|
|
3034
|
+
}
|
|
3035
|
+
});
|
|
3036
|
+
const books = (result[BRAND_NS.book] ?? []).filter((book) => isMember(book, ctx.actor.id));
|
|
3037
|
+
const bookById = new Map(books.map((book) => [book.id, book]));
|
|
3038
|
+
const items = books.map(
|
|
3039
|
+
(book) => projection(req, ctx, book, { kind: "brand:book", bookId: book.id }, book)
|
|
3040
|
+
);
|
|
3041
|
+
const append = (kind, rows) => {
|
|
3042
|
+
for (const row of rows) {
|
|
3043
|
+
const book = bookById.get(row.bookId);
|
|
3044
|
+
if (!book || kind === "brand:asset" && row.status !== "live") continue;
|
|
3045
|
+
items.push(projection(req, ctx, book, childTarget(kind, row), row));
|
|
3046
|
+
}
|
|
3047
|
+
};
|
|
3048
|
+
append("brand:asset", result[BRAND_NS.asset] ?? []);
|
|
3049
|
+
append("brand:palette", result[BRAND_NS.palette] ?? []);
|
|
3050
|
+
append("brand:proposal", result[BRAND_NS.proposal] ?? []);
|
|
3051
|
+
append("brand:receipt", result[BRAND_NS.approvalReceipt] ?? []);
|
|
3052
|
+
const needle = (url.searchParams.get("q") ?? "").trim().toLowerCase().slice(0, 120);
|
|
3053
|
+
return items.filter((item) => matches(item, needle)).slice(0, capLimit(url.searchParams.get("limit")));
|
|
3054
|
+
}
|
|
3055
|
+
async function handleBrandDiscussionReferences(ctx, req, url) {
|
|
3056
|
+
if (req.method !== "GET") return methodNotAllowed();
|
|
3057
|
+
const kind = url.searchParams.get("kind");
|
|
3058
|
+
const id = url.searchParams.get("id");
|
|
3059
|
+
if (kind && !id || !kind && id) return json({ error: "kind and id must be paired" }, 400);
|
|
3060
|
+
const target = targetFromQuery(url);
|
|
3061
|
+
if ((kind || id) && !target) return json({ items: [] });
|
|
3062
|
+
return json({
|
|
3063
|
+
items: target ? await exact(ctx, req, target) : await search(ctx, req, url)
|
|
1418
3064
|
});
|
|
1419
3065
|
}
|
|
1420
3066
|
|
|
1421
3067
|
// src/routes/index.ts
|
|
1422
3068
|
var tokensFile = (seg) => seg.length === 3 && seg[0] === "books" && (seg[2] === "tokens.css" || seg[2] === "tokens.json") ? seg[2] : null;
|
|
1423
|
-
async function route(ctx, req, seg) {
|
|
1424
|
-
const [head, id, sub, subId] = seg;
|
|
3069
|
+
async function route(ctx, req, url, seg) {
|
|
3070
|
+
const [head, id, sub, subId, action] = seg;
|
|
3071
|
+
if (head === "discussion-references" && seg.length === 1)
|
|
3072
|
+
return handleBrandDiscussionReferences(ctx, req, url);
|
|
1425
3073
|
if (head !== "books") return null;
|
|
1426
3074
|
if (seg.length === 1) return handleBooksRoot(ctx, req);
|
|
1427
3075
|
if (seg.length === 2) return handleBookItem(ctx, req, id);
|
|
3076
|
+
if (seg.length === 3 && sub === "members") return handleBookMembers(ctx, req, id);
|
|
1428
3077
|
if (sub === "assets") {
|
|
1429
3078
|
if (seg.length === 3) return handleAssetsRoot(ctx, req, id);
|
|
1430
3079
|
if (seg.length === 4) return handleAssetItem(ctx, req, id, subId);
|
|
3080
|
+
if (seg.length === 5 && action === "content")
|
|
3081
|
+
return handleAssetContent(ctx, req, id, subId);
|
|
1431
3082
|
return null;
|
|
1432
3083
|
}
|
|
3084
|
+
if (sub === "proposals" && seg.length === 5 && action === "resolve")
|
|
3085
|
+
return handleProposalResolution(ctx, req, id, subId);
|
|
1433
3086
|
const file = tokensFile(seg);
|
|
1434
3087
|
if (file) return handleTokens(ctx.db, req, id, file, ctx.actor);
|
|
1435
3088
|
return null;
|
|
@@ -1439,9 +3092,14 @@ function createBrandRoutes(options) {
|
|
|
1439
3092
|
const publicTokens = options.publicTokens === true;
|
|
1440
3093
|
const base = {
|
|
1441
3094
|
db: options.db,
|
|
3095
|
+
appId: options.appId,
|
|
3096
|
+
authorizeCapability: options.authorizeCapability,
|
|
3097
|
+
consumeHumanExact: options.consumeHumanExact,
|
|
3098
|
+
verifySourceAssetSnapshot: options.verifySourceAssetSnapshot,
|
|
1442
3099
|
now: options.now ?? Date.now,
|
|
1443
3100
|
newId: options.newId ?? (() => crypto.randomUUID()),
|
|
1444
|
-
maxUploadBytes: options.maxUploadBytes ?? 8 * 1024 * 1024
|
|
3101
|
+
maxUploadBytes: options.maxUploadBytes ?? 8 * 1024 * 1024,
|
|
3102
|
+
discussionBasePath: options.discussionBasePath ?? "/"
|
|
1445
3103
|
};
|
|
1446
3104
|
return async (req) => {
|
|
1447
3105
|
const url = new URL(req.url);
|
|
@@ -1450,15 +3108,44 @@ function createBrandRoutes(options) {
|
|
|
1450
3108
|
try {
|
|
1451
3109
|
const file = tokensFile(seg);
|
|
1452
3110
|
if (publicTokens && file) return await handleTokens(base.db, req, seg[1], file, null);
|
|
1453
|
-
const actor = await options.authorize(req);
|
|
3111
|
+
const actor = await (seg[0] === "discussion-references" ? options.authorizeDiscussionReferences ?? options.authorize : options.authorize)(req);
|
|
1454
3112
|
if (!actor) return json({ error: "unauthorized" }, 401);
|
|
1455
|
-
return await route({ ...base, actor }, req, seg) ?? json({ error: "not found" }, 404);
|
|
3113
|
+
return await route({ ...base, actor }, req, url, seg) ?? json({ error: "not found" }, 404);
|
|
1456
3114
|
} catch (error) {
|
|
1457
3115
|
return errorResponse(error);
|
|
1458
3116
|
}
|
|
1459
3117
|
};
|
|
1460
3118
|
}
|
|
1461
3119
|
|
|
3120
|
+
// src/dispatch-assets.ts
|
|
3121
|
+
var MAX_MESSAGE_ASSETS = 12;
|
|
3122
|
+
function candidateAssetIds(row) {
|
|
3123
|
+
const standard = Array.isArray(row.attachments) ? row.attachments.flatMap((value) => {
|
|
3124
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
3125
|
+
return [];
|
|
3126
|
+
const id = value.id;
|
|
3127
|
+
return typeof id === "string" && id.length > 0 && id.length <= 200 ? [id] : [];
|
|
3128
|
+
}) : [];
|
|
3129
|
+
const legacy = Array.isArray(row.assetIds) ? row.assetIds.filter((value) => typeof value === "string" && value.length > 0 && value.length <= 200) : [];
|
|
3130
|
+
return [.../* @__PURE__ */ new Set([...standard, ...legacy])].slice(0, MAX_MESSAGE_ASSETS);
|
|
3131
|
+
}
|
|
3132
|
+
async function attachedBrandAssetIds(db, book, row) {
|
|
3133
|
+
const candidates = candidateAssetIds(row);
|
|
3134
|
+
const assets = await Promise.all(candidates.map(async (id) => {
|
|
3135
|
+
const result = await db.query({
|
|
3136
|
+
[BRAND_NS.asset]: {
|
|
3137
|
+
$: { where: { id, bookId: book.id, status: "live" }, limit: 2 },
|
|
3138
|
+
book: { $: { limit: 2 } }
|
|
3139
|
+
}
|
|
3140
|
+
});
|
|
3141
|
+
const rows = result[BRAND_NS.asset] ?? [];
|
|
3142
|
+
if (rows.length !== 1) return null;
|
|
3143
|
+
const asset = rows[0];
|
|
3144
|
+
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;
|
|
3145
|
+
}));
|
|
3146
|
+
return assets.filter((id) => id !== null);
|
|
3147
|
+
}
|
|
3148
|
+
|
|
1462
3149
|
// src/triggers.ts
|
|
1463
3150
|
var CHAT_MESSAGE_NS = "chat_message";
|
|
1464
3151
|
var celString = (value) => value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
@@ -1480,73 +3167,133 @@ function brandBotTrigger(opts) {
|
|
|
1480
3167
|
// src/dispatch.ts
|
|
1481
3168
|
function parseBrandDispatch(raw) {
|
|
1482
3169
|
if (!raw || typeof raw !== "object") return null;
|
|
1483
|
-
const
|
|
1484
|
-
const
|
|
1485
|
-
const
|
|
1486
|
-
if (typeof
|
|
1487
|
-
if (!t || typeof t.runAs?.agentId !== "string" || typeof t.runAs?.persona !== "string") return null;
|
|
1488
|
-
if (!e || typeof e.id !== "string" || !e.row || typeof e.row !== "object") return null;
|
|
3170
|
+
const body = raw;
|
|
3171
|
+
const trigger = body.trigger;
|
|
3172
|
+
const event = body.event;
|
|
3173
|
+
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;
|
|
1489
3174
|
return {
|
|
1490
|
-
v:
|
|
1491
|
-
appId:
|
|
3175
|
+
v: 2,
|
|
3176
|
+
appId: body.appId,
|
|
3177
|
+
appIncarnation: body.appIncarnation,
|
|
3178
|
+
jobId: body.jobId,
|
|
1492
3179
|
trigger: {
|
|
1493
|
-
id: typeof
|
|
1494
|
-
skill: typeof
|
|
1495
|
-
runAs: {
|
|
1496
|
-
|
|
3180
|
+
id: typeof trigger.id === "string" ? trigger.id : "",
|
|
3181
|
+
skill: typeof trigger.skill === "string" ? trigger.skill : "",
|
|
3182
|
+
runAs: {
|
|
3183
|
+
agentId: trigger.runAs.agentId,
|
|
3184
|
+
persona: trigger.runAs.persona
|
|
3185
|
+
},
|
|
3186
|
+
...typeof trigger.maxDepth === "number" ? { maxDepth: trigger.maxDepth } : {}
|
|
1497
3187
|
},
|
|
1498
|
-
event: {
|
|
3188
|
+
event: {
|
|
3189
|
+
ns: typeof event.ns === "string" ? event.ns : CHAT_MESSAGE_NS,
|
|
3190
|
+
id: event.id,
|
|
3191
|
+
row: event.row
|
|
3192
|
+
}
|
|
1499
3193
|
};
|
|
1500
3194
|
}
|
|
1501
3195
|
async function bookForChannel(db, channelId) {
|
|
1502
3196
|
if (!channelId) return null;
|
|
1503
|
-
const
|
|
1504
|
-
|
|
3197
|
+
const result = await db.query({
|
|
3198
|
+
[BRAND_NS.book]: { $: { where: { channelId } } }
|
|
3199
|
+
});
|
|
3200
|
+
const books = result[BRAND_NS.book] ?? [];
|
|
3201
|
+
if (books.length > 1)
|
|
3202
|
+
throw new BrandConflictError(`multiple brand books claim channel ${channelId}`);
|
|
3203
|
+
return books[0] ?? null;
|
|
1505
3204
|
}
|
|
1506
|
-
function brandInputFor(body, attachments) {
|
|
3205
|
+
function brandInputFor(body, attachments, author, attachedAssetIds = []) {
|
|
1507
3206
|
const row = body.event.row;
|
|
1508
|
-
const
|
|
3207
|
+
const actor = author ? `${author.displayName} (${author.kind}${author.managerDisplayName ? `, managed by ${author.managerDisplayName}` : ""})` : row.authorKind === "bot" ? "a managed agent" : "a participant";
|
|
3208
|
+
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.";
|
|
1509
3209
|
return attachments?.length ? [...attachments, { type: "text", text: prompt }] : prompt;
|
|
1510
3210
|
}
|
|
1511
|
-
var PRETURN_IMAGE_TYPES = /* @__PURE__ */ new Set([
|
|
1512
|
-
"image/png",
|
|
1513
|
-
"image/jpeg",
|
|
1514
|
-
"image/gif",
|
|
1515
|
-
"image/webp"
|
|
1516
|
-
]);
|
|
1517
|
-
async function assetBlocksFor(db, fileBaseUrl, raw) {
|
|
1518
|
-
if (!Array.isArray(raw)) return [];
|
|
1519
|
-
const ids = raw.filter((v) => typeof v === "string");
|
|
1520
|
-
if (ids.length === 0) return [];
|
|
1521
|
-
const res = await db.query({ [BRAND_NS.asset]: { $: { where: { id: { $in: ids } } } } });
|
|
1522
|
-
const byId = new Map((res[BRAND_NS.asset] ?? []).map((a2) => [a2.id, a2]));
|
|
1523
|
-
const blocks = [];
|
|
1524
|
-
for (const id of ids) {
|
|
1525
|
-
const asset = byId.get(id);
|
|
1526
|
-
if (!asset || asset.deletedAt != null || !PRETURN_IMAGE_TYPES.has(asset.contentType)) continue;
|
|
1527
|
-
const url = asset.url.startsWith("http") ? asset.url : fileBaseUrl + asset.url;
|
|
1528
|
-
blocks.push({ type: "image", source: { type: "url", url } });
|
|
1529
|
-
}
|
|
1530
|
-
return blocks;
|
|
1531
|
-
}
|
|
1532
3211
|
async function dispatchBrandTurn(deps, body) {
|
|
3212
|
+
if (body.appId !== deps.appId || body.trigger.skill !== "brand" || body.event.ns !== CHAT_MESSAGE_NS) throw new BrandForbiddenError("foreign or non-brand dispatch");
|
|
1533
3213
|
const channelId = String(body.event.row.channelId ?? "");
|
|
1534
3214
|
const book = await bookForChannel(deps.db, channelId);
|
|
1535
|
-
if (!book)
|
|
1536
|
-
|
|
3215
|
+
if (!book)
|
|
3216
|
+
throw new BrandNotFoundError(
|
|
3217
|
+
`brand book for channel ${channelId || "(missing channelId)"}`
|
|
3218
|
+
);
|
|
3219
|
+
const agentId = body.trigger.runAs.agentId;
|
|
3220
|
+
if (!book.memberIds.includes(agentId))
|
|
3221
|
+
throw new BrandNotFoundError(`brand book for channel ${channelId}`);
|
|
3222
|
+
const read = await deps.authorizeCapability({
|
|
3223
|
+
agentId,
|
|
3224
|
+
bookId: book.id,
|
|
3225
|
+
capability: "brand.read"
|
|
3226
|
+
});
|
|
3227
|
+
if (!read || read.capability !== "brand.read" || !read.authorityRef)
|
|
3228
|
+
throw new BrandForbiddenError("agent lacks live brand.read");
|
|
3229
|
+
const authorId = typeof body.event.row.authorId === "string" ? body.event.row.authorId : "";
|
|
3230
|
+
const authors = authorId ? await deps.resolvePrincipals({
|
|
3231
|
+
requesterAgentId: agentId,
|
|
3232
|
+
bookId: book.id,
|
|
3233
|
+
principalIds: [authorId]
|
|
3234
|
+
}) : [];
|
|
3235
|
+
const author = authors.find((candidate) => candidate.id === authorId);
|
|
3236
|
+
const attachedAssetIds = await attachedBrandAssetIds(
|
|
3237
|
+
deps.db,
|
|
3238
|
+
book,
|
|
3239
|
+
body.event.row
|
|
3240
|
+
);
|
|
3241
|
+
const runtime = await deps.agentRuntimeFor({
|
|
3242
|
+
appId: body.appId,
|
|
3243
|
+
agentId,
|
|
3244
|
+
bookId: book.id,
|
|
3245
|
+
triggerId: body.trigger.id,
|
|
3246
|
+
eventId: body.event.id,
|
|
3247
|
+
jobId: body.jobId
|
|
3248
|
+
});
|
|
3249
|
+
if (!runtime.credentialRef || !runtime.jobId || runtime.jobId !== body.jobId)
|
|
3250
|
+
throw new BrandForbiddenError("agent data credential is not bound");
|
|
3251
|
+
let durableOutcome = false;
|
|
3252
|
+
const bridge = {
|
|
3253
|
+
...runtime.bridge,
|
|
3254
|
+
createProposal: async (input) => {
|
|
3255
|
+
const proposal = await runtime.bridge.createProposal(input);
|
|
3256
|
+
durableOutcome = true;
|
|
3257
|
+
return proposal;
|
|
3258
|
+
},
|
|
3259
|
+
recordAssetAnalysis: async (input) => {
|
|
3260
|
+
const asset = await runtime.bridge.recordAssetAnalysis(input);
|
|
3261
|
+
durableOutcome = true;
|
|
3262
|
+
return asset;
|
|
3263
|
+
}
|
|
3264
|
+
};
|
|
3265
|
+
const visionInToolResults = supportsBrandVision(
|
|
3266
|
+
deps.catalog?.[deps.model]
|
|
3267
|
+
);
|
|
1537
3268
|
const self = {
|
|
1538
|
-
selfId:
|
|
3269
|
+
selfId: agentId,
|
|
1539
3270
|
kind: "bot",
|
|
1540
3271
|
displayName: body.trigger.runAs.persona
|
|
1541
3272
|
};
|
|
1542
3273
|
const persona = createBrandPersona({
|
|
1543
3274
|
model: deps.model,
|
|
1544
|
-
brand: {
|
|
3275
|
+
brand: {
|
|
3276
|
+
db: runtime.db,
|
|
3277
|
+
bookId: book.id,
|
|
3278
|
+
self,
|
|
3279
|
+
agentDbBinding: {
|
|
3280
|
+
principalId: agentId,
|
|
3281
|
+
credentialRef: runtime.credentialRef
|
|
3282
|
+
},
|
|
3283
|
+
agentJobId: runtime.jobId,
|
|
3284
|
+
agentBridge: bridge,
|
|
3285
|
+
authorizeCapability: deps.authorizeCapability,
|
|
3286
|
+
resolvePrincipals: deps.resolvePrincipals,
|
|
3287
|
+
visionInToolResults,
|
|
3288
|
+
...deps.newId ? { newId: deps.newId } : {}
|
|
3289
|
+
},
|
|
1545
3290
|
skills: deps.chatSkillFor ? [deps.chatSkillFor(channelId, self)] : []
|
|
1546
3291
|
});
|
|
1547
|
-
const
|
|
1548
|
-
|
|
1549
|
-
|
|
3292
|
+
const run = await deps.runAgent(deps.inference, persona, {
|
|
3293
|
+
// Models without tool-result vision receive no raw/signed fallback URL.
|
|
3294
|
+
input: brandInputFor(body, void 0, author, attachedAssetIds)
|
|
3295
|
+
});
|
|
3296
|
+
return { finalText: run.finalText, durableOutcome };
|
|
1550
3297
|
}
|
|
1551
3298
|
|
|
1552
3299
|
// src/descriptor.ts
|
|
@@ -1574,10 +3321,12 @@ var brandIntegration = {
|
|
|
1574
3321
|
schema: BRAND_SCHEMA,
|
|
1575
3322
|
rules: brandRules(),
|
|
1576
3323
|
triggers: [],
|
|
3324
|
+
agentProfile: BRAND_AGENT_PROFILE,
|
|
1577
3325
|
provision: {
|
|
1578
3326
|
human: [
|
|
3327
|
+
"Configure direct Clerk human approval through authorizeCapability + consumeHumanExact; delegated, machine, and agent principals must not resolve proposals.",
|
|
1579
3328
|
'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).',
|
|
1580
|
-
"Pick
|
|
3329
|
+
"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.",
|
|
1581
3330
|
"Decide whether compiled tokens are public (publicTokens serves tokens.css/tokens.json unauthenticated)."
|
|
1582
3331
|
],
|
|
1583
3332
|
cli: [
|
|
@@ -1585,10 +3334,14 @@ var brandIntegration = {
|
|
|
1585
3334
|
"Install brandRules() at /app/:id/admin/rules (merged with the app's existing rules).",
|
|
1586
3335
|
"Register a brandBotTrigger(...) per bot at /app/:id/admin/triggers \u2014 global mention-gated, or channel-scoped per book.",
|
|
1587
3336
|
"Seed the bot's agent id into each brand_book.memberIds roster (audienceFanoutOps covers existing child rows).",
|
|
1588
|
-
"
|
|
3337
|
+
"Issue the bot brand.read + brand.edit and expose only BRAND_AGENT_PROFILE semantic operations; deny raw brand_* writes and raw file operations.",
|
|
3338
|
+
"Provision private file signing and verifySourceAssetSnapshot; never expose an admin credential or persistent asset URL to the agent."
|
|
1589
3339
|
],
|
|
1590
3340
|
doctor: [
|
|
1591
|
-
"All
|
|
3341
|
+
"All six brand_* namespaces have rules installed; raw browser/agent writes are denied and child reads use current linked-book membership.",
|
|
3342
|
+
"The agent runtime matches BRAND_AGENT_PROFILE: brand.read/edit only, semantic operations only, no raw brand_* or file access.",
|
|
3343
|
+
"Proposal decisions consume a direct-human exact brand.approve authority use and persist one guarded brand_approval_receipt.",
|
|
3344
|
+
"Private source assets are signed briefly and verifySourceAssetSnapshot revalidates path, ETag, size, content type, and digest before approval.",
|
|
1592
3345
|
"brand_book.id/slug, brand_section.key, and the other mirrored id attrs are unique.",
|
|
1593
3346
|
"Each registered trigger's agent id is present in its target books' memberIds rosters.",
|
|
1594
3347
|
"Books with channelId set point at real chat channels when @odla-ai/chat is installed."
|
|
@@ -1615,17 +3368,23 @@ export {
|
|
|
1615
3368
|
ASSET_CONTENT_TYPES,
|
|
1616
3369
|
ASSET_KINDS,
|
|
1617
3370
|
BOOK_STATUSES,
|
|
3371
|
+
BRAND_AGENT_PROFILE,
|
|
1618
3372
|
BRAND_CHART_TOKENS,
|
|
1619
3373
|
BRAND_CHAT_TOKENS,
|
|
1620
3374
|
BRAND_DERIVED_TOKENS,
|
|
3375
|
+
BRAND_DISCUSSION_REFERENCE_KINDS,
|
|
1621
3376
|
BRAND_EMITTED_TOKENS,
|
|
1622
3377
|
BRAND_INSTRUCTIONS,
|
|
1623
3378
|
BRAND_NS,
|
|
1624
3379
|
BRAND_REQUIRED_TOKENS,
|
|
1625
3380
|
BRAND_RULES,
|
|
1626
3381
|
BRAND_SCHEMA,
|
|
3382
|
+
BrandConflictError,
|
|
3383
|
+
BrandForbiddenError,
|
|
3384
|
+
BrandGoneError,
|
|
1627
3385
|
BrandInputError,
|
|
1628
3386
|
BrandNotFoundError,
|
|
3387
|
+
BrandReviewStateChangedError,
|
|
1629
3388
|
CHART_DELTA_MIN,
|
|
1630
3389
|
CHAT_MESSAGE_NS,
|
|
1631
3390
|
CSS_NAMED_COLORS,
|
|
@@ -1654,15 +3413,22 @@ export {
|
|
|
1654
3413
|
assertSectionContent,
|
|
1655
3414
|
assertSwatches,
|
|
1656
3415
|
assetTools,
|
|
3416
|
+
attachedBrandAssetIds,
|
|
1657
3417
|
audienceFanoutOps,
|
|
1658
3418
|
base64FromBytes,
|
|
3419
|
+
beginAssetDeleteOps,
|
|
1659
3420
|
bookForChannel,
|
|
1660
3421
|
bookTools,
|
|
3422
|
+
brandAuthorityRef,
|
|
1661
3423
|
brandBotTrigger,
|
|
3424
|
+
brandDiscussionReferenceHref,
|
|
3425
|
+
brandDiscussionReferenceId,
|
|
1662
3426
|
brandInputFor,
|
|
1663
3427
|
brandIntegration,
|
|
3428
|
+
brandJsonDigest,
|
|
1664
3429
|
brandRules,
|
|
1665
3430
|
brandSkill,
|
|
3431
|
+
canonicalBrandJson,
|
|
1666
3432
|
capString,
|
|
1667
3433
|
capStringArray,
|
|
1668
3434
|
clamp01,
|
|
@@ -1681,9 +3447,13 @@ export {
|
|
|
1681
3447
|
deriveDarkTokens,
|
|
1682
3448
|
derivePalette,
|
|
1683
3449
|
dispatchBrandTurn,
|
|
3450
|
+
finishAssetDeleteOps,
|
|
3451
|
+
formatBrandDiscussionReference,
|
|
1684
3452
|
hexToOklch,
|
|
1685
3453
|
hslToRgb,
|
|
1686
3454
|
inSrgbGamut,
|
|
3455
|
+
isBoundedBrandJson,
|
|
3456
|
+
isBrandHumanAuthorityConsumption,
|
|
1687
3457
|
linearToSrgb,
|
|
1688
3458
|
mapPaletteToTokens,
|
|
1689
3459
|
meetsAA,
|
|
@@ -1696,10 +3466,13 @@ export {
|
|
|
1696
3466
|
oklchToHex,
|
|
1697
3467
|
oklchToOklab,
|
|
1698
3468
|
paletteTools,
|
|
3469
|
+
parseBrandDiscussionReference,
|
|
1699
3470
|
parseBrandDispatch,
|
|
1700
3471
|
parseHex,
|
|
1701
3472
|
pickTextOn,
|
|
3473
|
+
proposalReviewSnapshot,
|
|
1702
3474
|
proposePaletteOps,
|
|
3475
|
+
proposeSectionOps,
|
|
1703
3476
|
readTools,
|
|
1704
3477
|
recordAnalysisOps,
|
|
1705
3478
|
rejectProposalOps,
|
|
@@ -1717,9 +3490,9 @@ export {
|
|
|
1717
3490
|
tetradic,
|
|
1718
3491
|
tintShadeRamp,
|
|
1719
3492
|
toHex,
|
|
1720
|
-
tombstoneAssetOps,
|
|
1721
3493
|
triadic,
|
|
1722
3494
|
updateBookOps,
|
|
1723
|
-
upsertSectionOps
|
|
3495
|
+
upsertSectionOps,
|
|
3496
|
+
verifyBrandApprovalReceipt
|
|
1724
3497
|
};
|
|
1725
3498
|
//# sourceMappingURL=index.js.map
|