@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 +17 -0
- package/dist/index.cjs +148 -4
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +18 -1
- package/dist/index.d.ts +18 -1
- package/dist/index.js +148 -4
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
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
|
|
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:${
|
|
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);
|