@odla-ai/brand 0.3.0 → 0.4.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 CHANGED
@@ -71,6 +71,8 @@ const routes = createBrandRoutes({
71
71
  authorize: verifyDirectCaller,
72
72
  // Read-only: exact brand/brand.read/audience/app-incarnation assertion.
73
73
  authorizeDiscussionReferences: verifyDiscussionReferenceAssertion,
74
+ // Optional test/edge fetch; it receives only a Brand-minted private URL.
75
+ fetchPrivateAsset: fetch,
74
76
  authorizeCapability: verifyCurrentHumanAppOwner,
75
77
  consumeHumanExact: async (input) =>
76
78
  (await appsForBearer(input.req).consumeHumanExactAuthority(
@@ -108,6 +110,21 @@ manager and live grant revision. The host must set its current
108
110
  `ODLA_APP_INCARNATION` and fail closed on a mismatch. This assertion cannot
109
111
  authorize proposal resolution or any other Brand mutation.
110
112
 
113
+ Palette projections additionally carry at most 24 validated semantic roles,
114
+ normalized `#rrggbb` colors, and bounded optional names so Discussion can use
115
+ the shared `@odla-ai/ui` `PaletteStrip` without reconstructing brand state.
116
+ An assertion-authorized exact lookup may request
117
+ `inspect=asset-content&kind=brand:asset&id=…`. Brand resolves the linked row,
118
+ mints its own short-lived private-object URL, and returns only bounded PNG,
119
+ JPEG, GIF, WebP, or PDF bytes—never the URL or a separate object path, storage
120
+ id, app id, or book id. Only the already-discovered canonical reference id
121
+ crosses the boundary. It validates type and magic, size, and SHA-256, then
122
+ reloads both
123
+ membership and the exact linked asset row after I/O. SVG, malformed, spoofed,
124
+ changed, unsupported, and over-budget assets fail closed. Returned bytes carry
125
+ `untrusted_project_material` taint for the hosted inference boundary; the
126
+ native link remains navigation only and human proposal approval is unchanged.
127
+
111
128
  Resolve a proposal from a human review UI with one stable mutation id. Send
112
129
  back exactly the proposal snapshot that UI rendered; a stale or concurrently
113
130
  resolved snapshot answers `409`, while an identical retry returns the original
package/dist/index.cjs CHANGED
@@ -3426,13 +3426,13 @@ async function proposalDecisionOps(input) {
3426
3426
 
3427
3427
  // src/routes/proposal-resolution-receipts.ts
3428
3428
  async function proposalResolutionMutationKey(bookId, proposalId, mutationId) {
3429
- const digest = await brandJsonDigest({
3429
+ const digest2 = await brandJsonDigest({
3430
3430
  version: 3,
3431
3431
  bookId,
3432
3432
  proposalId,
3433
3433
  mutationId
3434
3434
  });
3435
- return `brand:proposal-resolution:v3:${digest.slice("sha256:".length)}`;
3435
+ return `brand:proposal-resolution:v3:${digest2.slice("sha256:".length)}`;
3436
3436
  }
3437
3437
  async function proposalReceiptByMutation(ctx, mutationKey) {
3438
3438
  const result = await ctx.db.query({
@@ -3982,6 +3982,130 @@ async function handleTokens(db, req, bookId, which, actor) {
3982
3982
  });
3983
3983
  }
3984
3984
 
3985
+ // src/routes/discussion-asset.ts
3986
+ var MAX_DISCUSSION_ASSET_BYTES = 4718592;
3987
+ var SIGN_TTL_SECONDS = 30;
3988
+ var TYPES = /* @__PURE__ */ new Set([
3989
+ "image/png",
3990
+ "image/jpeg",
3991
+ "image/gif",
3992
+ "image/webp",
3993
+ "application/pdf"
3994
+ ]);
3995
+ var bare = (value) => (value?.split(";", 1)[0] ?? "").trim().toLowerCase();
3996
+ var starts = (bytes, expected) => expected.every((value, index) => bytes[index] === value);
3997
+ function magicMatches(contentType, bytes) {
3998
+ if (contentType === "image/png") {
3999
+ return starts(bytes, [137, 80, 78, 71, 13, 10, 26, 10]);
4000
+ }
4001
+ if (contentType === "image/jpeg") {
4002
+ return starts(bytes, [255, 216, 255]);
4003
+ }
4004
+ const prefix = new TextDecoder().decode(bytes.subarray(0, 16));
4005
+ if (contentType === "image/gif") {
4006
+ return prefix.startsWith("GIF87a") || prefix.startsWith("GIF89a");
4007
+ }
4008
+ if (contentType === "image/webp") {
4009
+ return prefix.startsWith("RIFF") && prefix.slice(8, 12) === "WEBP";
4010
+ }
4011
+ return contentType === "application/pdf" && prefix.startsWith("%PDF-");
4012
+ }
4013
+ async function boundedBytes(response, max) {
4014
+ const length = Number(response.headers.get("content-length"));
4015
+ if (Number.isFinite(length) && length > max) return null;
4016
+ if (!response.body) return new Uint8Array();
4017
+ const reader = response.body.getReader();
4018
+ const chunks = [];
4019
+ let total = 0;
4020
+ while (true) {
4021
+ const next = await reader.read();
4022
+ if (next.done) break;
4023
+ total += next.value.byteLength;
4024
+ if (total > max) {
4025
+ await reader.cancel();
4026
+ return null;
4027
+ }
4028
+ chunks.push(next.value);
4029
+ }
4030
+ const out = new Uint8Array(total);
4031
+ let offset = 0;
4032
+ for (const chunk of chunks) {
4033
+ out.set(chunk, offset);
4034
+ offset += chunk.byteLength;
4035
+ }
4036
+ return out;
4037
+ }
4038
+ async function digest(bytes) {
4039
+ const value = await crypto.subtle.digest("SHA-256", bytes.slice().buffer);
4040
+ return `sha256:${[...new Uint8Array(value)].map((byte) => byte.toString(16).padStart(2, "0")).join("")}`;
4041
+ }
4042
+ function exactAsset(beforeBook, before, afterBook, after) {
4043
+ return afterBook.id === beforeBook.id && after.id === before.id && after.bookId === before.bookId && after.status === "live" && after.path === before.path && after.storageObjectId === before.storageObjectId && after.contentDigest === before.contentDigest && after.contentType === before.contentType && after.size === before.size;
4044
+ }
4045
+ function safeSignedUrl(value) {
4046
+ try {
4047
+ const url = new URL(value);
4048
+ return value.length <= 4096 && url.protocol === "https:" && !url.username && !url.password ? url : null;
4049
+ } catch {
4050
+ return null;
4051
+ }
4052
+ }
4053
+ async function handleBrandDiscussionAsset(ctx, req, target) {
4054
+ const beforeBook = await loadMemberBook(ctx.db, target.bookId, ctx.actor.id);
4055
+ const before = await linkedAsset(ctx, beforeBook, target.resourceId);
4056
+ const contentType = bare(before.contentType);
4057
+ if (!TYPES.has(contentType)) {
4058
+ return json({ error: "asset content type is not viewable" }, 415);
4059
+ }
4060
+ if (before.size < 1 || before.size > MAX_DISCUSSION_ASSET_BYTES) {
4061
+ return json({ error: "asset exceeds the discussion media limit" }, 413);
4062
+ }
4063
+ const signed = safeSignedUrl(
4064
+ await ctx.db.storage.sign(before.path, SIGN_TTL_SECONDS)
4065
+ );
4066
+ if (!signed) throw new BrandInputError("private asset signing failed");
4067
+ const response = await ctx.fetchPrivateAsset(signed, {
4068
+ headers: { accept: contentType },
4069
+ redirect: "manual",
4070
+ signal: req.signal
4071
+ });
4072
+ if (!response.ok || response.type === "opaqueredirect") {
4073
+ throw new BrandNotFoundError(`asset ${before.id}`);
4074
+ }
4075
+ const served = bare(response.headers.get("content-type"));
4076
+ if (served === "image/svg+xml" || served && served !== "application/octet-stream" && served !== contentType) {
4077
+ return json({ error: "asset content type is inconsistent" }, 415);
4078
+ }
4079
+ const bytes = await boundedBytes(response, MAX_DISCUSSION_ASSET_BYTES);
4080
+ if (!bytes || bytes.byteLength !== before.size || !magicMatches(contentType, bytes) || await digest(bytes) !== before.contentDigest) {
4081
+ return json({ error: "asset bytes failed validation" }, 422);
4082
+ }
4083
+ const afterBook = await loadMemberBook(ctx.db, target.bookId, ctx.actor.id);
4084
+ const after = await linkedAsset(ctx, afterBook, target.resourceId);
4085
+ if (!exactAsset(beforeBook, before, afterBook, after)) {
4086
+ return json({ error: "asset changed during inspection" }, 409);
4087
+ }
4088
+ return json({
4089
+ asset: {
4090
+ reference: { kind: "brand:asset", id: `${before.bookId}/${before.id}` },
4091
+ contentType,
4092
+ byteLength: bytes.byteLength,
4093
+ data: base64(bytes),
4094
+ taint: "untrusted_project_material"
4095
+ }
4096
+ }, 200, {
4097
+ "cache-control": "private, no-store",
4098
+ "x-content-type-options": "nosniff"
4099
+ });
4100
+ }
4101
+ function base64(bytes) {
4102
+ let binary = "";
4103
+ for (let index = 0; index < bytes.length; index += 8192) {
4104
+ binary += String.fromCharCode(...bytes.subarray(index, index + 8192));
4105
+ }
4106
+ return btoa(binary);
4107
+ }
4108
+
3985
4109
  // src/routes/discussion-references.ts
3986
4110
  var capLimit = (raw) => {
3987
4111
  const parsed = Number(raw ?? 20);
@@ -4013,6 +4137,7 @@ var projection = (req, ctx, book, target, row) => {
4013
4137
  let summary = `${book.name} brand book`;
4014
4138
  let status = book.status;
4015
4139
  let destination = "Brand Studio \xB7 Brand book";
4140
+ let swatches;
4016
4141
  if (target.kind === "brand:asset") {
4017
4142
  const asset = row;
4018
4143
  label = asset.title?.trim() || `${asset.kind} asset`;
@@ -4022,6 +4147,11 @@ var projection = (req, ctx, book, target, row) => {
4022
4147
  destination = "Brand Studio \xB7 Conversation";
4023
4148
  } else if (target.kind === "brand:palette") {
4024
4149
  const palette = row;
4150
+ swatches = assertSwatches(palette.swatches).map((swatch) => ({
4151
+ role: swatch.role,
4152
+ hex: swatch.hex,
4153
+ ...swatch.name ? { name: swatch.name } : {}
4154
+ }));
4025
4155
  label = palette.name;
4026
4156
  hint = `${book.name} \xB7 ${palette.status} palette`;
4027
4157
  summary = `${palette.swatches.length}-color palette in ${book.name}`;
@@ -4054,7 +4184,8 @@ var projection = (req, ctx, book, target, row) => {
4054
4184
  new URL(req.url),
4055
4185
  target,
4056
4186
  ctx.discussionBasePath
4057
- )
4187
+ ),
4188
+ ...swatches ? { swatches } : {}
4058
4189
  };
4059
4190
  };
4060
4191
  async function exact(ctx, req, target) {
@@ -4108,11 +4239,23 @@ async function search(ctx, req, url) {
4108
4239
  }
4109
4240
  async function handleBrandDiscussionReferences(ctx, req, url) {
4110
4241
  if (req.method !== "GET") return methodNotAllowed();
4242
+ const inspect = url.searchParams.get("inspect");
4111
4243
  const kind = url.searchParams.get("kind");
4112
4244
  const id = url.searchParams.get("id");
4113
4245
  if (kind && !id || !kind && id) return json({ error: "kind and id must be paired" }, 400);
4114
4246
  const target = targetFromQuery(url);
4115
4247
  if ((kind || id) && !target) return json({ items: [] });
4248
+ if (inspect === "asset-content") {
4249
+ const allowed = /* @__PURE__ */ new Set(["inspect", "kind", "id"]);
4250
+ if ([...url.searchParams.keys()].some((key) => !allowed.has(key)) || target?.kind !== "brand:asset" || !target.resourceId) return json({ error: "exact brand asset kind and id required" }, 400);
4251
+ return handleBrandDiscussionAsset(ctx, req, {
4252
+ bookId: target.bookId,
4253
+ resourceId: target.resourceId
4254
+ });
4255
+ }
4256
+ if (inspect !== null && inspect !== "1") {
4257
+ return json({ error: "invalid inspection mode" }, 400);
4258
+ }
4116
4259
  return json({
4117
4260
  items: target ? await exact(ctx, req, target) : await search(ctx, req, url)
4118
4261
  });
@@ -4153,7 +4296,8 @@ function createBrandRoutes(options) {
4153
4296
  now: options.now ?? Date.now,
4154
4297
  newId: options.newId ?? (() => crypto.randomUUID()),
4155
4298
  maxUploadBytes: options.maxUploadBytes ?? 8 * 1024 * 1024,
4156
- discussionBasePath: options.discussionBasePath ?? "/"
4299
+ discussionBasePath: options.discussionBasePath ?? "/",
4300
+ fetchPrivateAsset: options.fetchPrivateAsset ?? fetch
4157
4301
  };
4158
4302
  return async (req) => {
4159
4303
  const url = new URL(req.url);