@agent-native/core 0.124.4 → 0.124.6
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/corpus/core/CHANGELOG.md +12 -0
- package/corpus/core/package.json +1 -1
- package/corpus/core/src/ingestion/pptx.ts +259 -5
- package/corpus/core/src/templates/default/.agents/skills/secrets/SKILL.md +28 -3
- package/corpus/core/src/templates/headless/.agents/skills/secrets/SKILL.md +28 -3
- package/corpus/core/src/templates/workspace-core/.agents/skills/secrets/SKILL.md +28 -3
- package/corpus/templates/slides/actions/import-file.ts +81 -8
- package/corpus/templates/slides/actions/import-pptx.ts +100 -18
- package/corpus/templates/slides/server/handlers/import/html-converter.ts +129 -27
- package/dist/collab/routes.d.ts +1 -1
- package/dist/ingestion/pptx.d.ts +4 -0
- package/dist/ingestion/pptx.d.ts.map +1 -1
- package/dist/ingestion/pptx.js +207 -2
- package/dist/ingestion/pptx.js.map +1 -1
- package/dist/notifications/routes.d.ts +3 -3
- package/dist/progress/routes.d.ts +1 -1
- package/dist/resources/handlers.d.ts +1 -1
- package/dist/secrets/routes.d.ts +6 -6
- package/dist/templates/default/.agents/skills/secrets/SKILL.md +28 -3
- package/dist/templates/headless/.agents/skills/secrets/SKILL.md +28 -3
- package/dist/templates/workspace-core/.agents/skills/secrets/SKILL.md +28 -3
- package/package.json +1 -1
- package/src/ingestion/pptx.ts +259 -5
- package/src/templates/default/.agents/skills/secrets/SKILL.md +28 -3
- package/src/templates/headless/.agents/skills/secrets/SKILL.md +28 -3
- package/src/templates/workspace-core/.agents/skills/secrets/SKILL.md +28 -3
package/corpus/core/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# @agent-native/core
|
|
2
2
|
|
|
3
|
+
## 0.124.6
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- ac8794b: Clarify credential resolver organization scope in generated workspace guidance.
|
|
8
|
+
|
|
9
|
+
## 0.124.5
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- fc5f2db: Extract picture shape size and slide dimensions when parsing PPTX files, so importers can tell a full-bleed cover photo from a small inset image and preserve each image's real aspect ratio.
|
|
14
|
+
|
|
3
15
|
## 0.124.4
|
|
4
16
|
|
|
5
17
|
### Patch Changes
|
package/corpus/core/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agent-native/core",
|
|
3
|
-
"version": "0.124.
|
|
3
|
+
"version": "0.124.6",
|
|
4
4
|
"description": "Framework for agent-native application development — where AI agents and UI share SQL state, actions, and context",
|
|
5
5
|
"homepage": "https://github.com/BuilderIO/agent-native#readme",
|
|
6
6
|
"bugs": {
|
|
@@ -10,6 +10,10 @@ export interface ParsedPptxImage {
|
|
|
10
10
|
data: Uint8Array;
|
|
11
11
|
mimeType: string;
|
|
12
12
|
name: string;
|
|
13
|
+
/** Width / height of the picture shape on the slide, from its own placed size (not the source file's pixel dimensions). */
|
|
14
|
+
aspectRatio?: number;
|
|
15
|
+
/** True when the picture shape covers at least ~85% of the slide's width and height — a full-bleed background photo rather than an inset card image. */
|
|
16
|
+
fullBleed?: boolean;
|
|
13
17
|
}
|
|
14
18
|
|
|
15
19
|
export interface ParsedPptxSlide {
|
|
@@ -46,11 +50,13 @@ export async function parsePptxPresentation(
|
|
|
46
50
|
if (!presentationXml)
|
|
47
51
|
throw new Error("Invalid PPTX: missing ppt/presentation.xml");
|
|
48
52
|
const presentation = parseXml(presentationXml);
|
|
53
|
+
const presentationRoot = record(record(presentation)?.["p:presentation"]);
|
|
49
54
|
const slideIds = asArray(
|
|
50
|
-
record(
|
|
51
|
-
"p:sldId"
|
|
52
|
-
],
|
|
55
|
+
record(presentationRoot?.["p:sldIdLst"])?.["p:sldId"],
|
|
53
56
|
).map((entry) => stringValue(record(entry)?.["@_r:id"]) ?? "");
|
|
57
|
+
const sldSz = record(presentationRoot?.["p:sldSz"]);
|
|
58
|
+
const slideWidthEmu = Number(sldSz?.["@_cx"]) || undefined;
|
|
59
|
+
const slideHeightEmu = Number(sldSz?.["@_cy"]) || undefined;
|
|
54
60
|
const relationshipsXml = await zip
|
|
55
61
|
.file("ppt/_rels/presentation.xml.rels")
|
|
56
62
|
?.async("string");
|
|
@@ -95,9 +101,27 @@ export async function parsePptxPresentation(
|
|
|
95
101
|
.file(relationshipPath)
|
|
96
102
|
?.async("string");
|
|
97
103
|
if (slideRelationshipsXml) {
|
|
98
|
-
|
|
104
|
+
const slideRelationships = parseRelationships(
|
|
99
105
|
parseXml(slideRelationshipsXml),
|
|
100
|
-
)
|
|
106
|
+
);
|
|
107
|
+
// Walk the slide's own picture shapes (in document order) rather than
|
|
108
|
+
// every image relationship, so each image carries the placed size the
|
|
109
|
+
// author gave it on the slide — that size is what tells a full-bleed
|
|
110
|
+
// cover photo apart from a small inset card photo, which a flat
|
|
111
|
+
// relationship scan has no way to know.
|
|
112
|
+
const pictureShapes: PictureShape[] = [];
|
|
113
|
+
collectPictureShapes(slide, pictureShapes);
|
|
114
|
+
reorderByDocumentPosition(pictureShapes, xml);
|
|
115
|
+
addBackgroundFillShape(
|
|
116
|
+
slide,
|
|
117
|
+
pictureShapes,
|
|
118
|
+
slideWidthEmu,
|
|
119
|
+
slideHeightEmu,
|
|
120
|
+
);
|
|
121
|
+
for (const shape of pictureShapes) {
|
|
122
|
+
if (!shape.embedId) continue;
|
|
123
|
+
const relationship = slideRelationships.get(shape.embedId);
|
|
124
|
+
if (!relationship) continue;
|
|
101
125
|
if (
|
|
102
126
|
!relationship.type.includes("/image") &&
|
|
103
127
|
!/\.(png|jpe?g|gif|svg|webp|bmp|tiff?|emf|wmf)$/i.test(
|
|
@@ -114,10 +138,24 @@ export async function parsePptxPresentation(
|
|
|
114
138
|
const image = zip.file(imagePath);
|
|
115
139
|
if (!image) continue;
|
|
116
140
|
const name = imagePath.split("/").at(-1) ?? "image";
|
|
141
|
+
const aspectRatio =
|
|
142
|
+
shape.widthEmu && shape.heightEmu
|
|
143
|
+
? shape.widthEmu / shape.heightEmu
|
|
144
|
+
: undefined;
|
|
145
|
+
const fullBleed = Boolean(
|
|
146
|
+
shape.widthEmu &&
|
|
147
|
+
shape.heightEmu &&
|
|
148
|
+
slideWidthEmu &&
|
|
149
|
+
slideHeightEmu &&
|
|
150
|
+
shape.widthEmu / slideWidthEmu >= 0.85 &&
|
|
151
|
+
shape.heightEmu / slideHeightEmu >= 0.85,
|
|
152
|
+
);
|
|
117
153
|
images.push({
|
|
118
154
|
data: new Uint8Array(await image.async("nodebuffer")),
|
|
119
155
|
mimeType: imageMimeType(name),
|
|
120
156
|
name,
|
|
157
|
+
aspectRatio,
|
|
158
|
+
fullBleed,
|
|
121
159
|
});
|
|
122
160
|
}
|
|
123
161
|
}
|
|
@@ -204,6 +242,222 @@ function collectTextRuns(
|
|
|
204
242
|
}
|
|
205
243
|
}
|
|
206
244
|
|
|
245
|
+
interface PictureShape {
|
|
246
|
+
embedId?: string;
|
|
247
|
+
widthEmu?: number;
|
|
248
|
+
heightEmu?: number;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Recursively find every embedded picture in a slide, in document order:
|
|
253
|
+
* plain `p:pic` shapes, and `p:sp` autoshapes whose fill is a picture
|
|
254
|
+
* (common for "photo cutout" shapes and full-bleed decorative rectangles).
|
|
255
|
+
* A group shape (`p:grpSp`) defines its own local coordinate space for its
|
|
256
|
+
* children (`chOff`/`chExt`) that can be scaled arbitrarily relative to the
|
|
257
|
+
* group's own placed size on the slide, so each level of nesting multiplies
|
|
258
|
+
* a running scale factor into the child sizes below it — without that, a
|
|
259
|
+
* picture inside a resized group would report its unscaled design-time
|
|
260
|
+
* size instead of how large it actually appears on the slide.
|
|
261
|
+
*/
|
|
262
|
+
function collectPictureShapes(
|
|
263
|
+
value: unknown,
|
|
264
|
+
out: PictureShape[],
|
|
265
|
+
scaleX = 1,
|
|
266
|
+
scaleY = 1,
|
|
267
|
+
): void {
|
|
268
|
+
const node = record(value);
|
|
269
|
+
if (!node) return;
|
|
270
|
+
for (const [key, child] of Object.entries(node)) {
|
|
271
|
+
if (key.startsWith("@_")) continue;
|
|
272
|
+
if (key === "p:pic") {
|
|
273
|
+
for (const picNode of asArray(child)) {
|
|
274
|
+
const pic = record(picNode);
|
|
275
|
+
const blip = record(record(pic?.["p:blipFill"])?.["a:blip"]);
|
|
276
|
+
out.push(
|
|
277
|
+
scaledShape(
|
|
278
|
+
stringValue(blip?.["@_r:embed"]),
|
|
279
|
+
extFromSpPr(pic),
|
|
280
|
+
scaleX,
|
|
281
|
+
scaleY,
|
|
282
|
+
),
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
if (key === "p:sp") {
|
|
288
|
+
for (const spNode of asArray(child)) {
|
|
289
|
+
const sp = record(spNode);
|
|
290
|
+
const blip = record(
|
|
291
|
+
record(record(sp?.["p:spPr"])?.["a:blipFill"])?.["a:blip"],
|
|
292
|
+
);
|
|
293
|
+
const embedId = stringValue(blip?.["@_r:embed"]);
|
|
294
|
+
if (embedId) {
|
|
295
|
+
out.push(scaledShape(embedId, extFromSpPr(sp), scaleX, scaleY));
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
continue;
|
|
299
|
+
}
|
|
300
|
+
if (key === "p:grpSp") {
|
|
301
|
+
for (const groupNode of asArray(child)) {
|
|
302
|
+
const scale = groupChildScale(record(groupNode), scaleX, scaleY);
|
|
303
|
+
collectPictureShapes(groupNode, out, scale.x, scale.y);
|
|
304
|
+
}
|
|
305
|
+
continue;
|
|
306
|
+
}
|
|
307
|
+
for (const item of asArray(child)) {
|
|
308
|
+
collectPictureShapes(item, out, scaleX, scaleY);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** Whether an `a:xfrm` `rot` value (60,000ths of a degree) is an odd multiple of 90° — a 90/270 turn that swaps effective width and height. */
|
|
314
|
+
function isOddQuarterTurn(rot: number): boolean {
|
|
315
|
+
if (!Number.isFinite(rot)) return false;
|
|
316
|
+
const quarterTurns = Math.round(rot / 60000 / 90);
|
|
317
|
+
return ((quarterTurns % 2) + 2) % 2 === 1;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* A group's `chExt` is the coordinate space its children are authored in;
|
|
322
|
+
* `ext` is how large the group actually renders — the ratio between them is
|
|
323
|
+
* the extra scale nested children need on top of their own declared size.
|
|
324
|
+
* When the group itself is rotated a 90/270-degree turn, that ratio applies
|
|
325
|
+
* to the perpendicular axis on the slide, so the X/Y scale factors need
|
|
326
|
+
* swapping the same way a rotated picture's own width/height do.
|
|
327
|
+
*/
|
|
328
|
+
function groupChildScale(
|
|
329
|
+
groupNode: Record<string, unknown> | null,
|
|
330
|
+
parentScaleX: number,
|
|
331
|
+
parentScaleY: number,
|
|
332
|
+
): { x: number; y: number } {
|
|
333
|
+
const xfrm = record(record(groupNode?.["p:grpSpPr"])?.["a:xfrm"]);
|
|
334
|
+
const ext = record(xfrm?.["a:ext"]);
|
|
335
|
+
const chExt = record(xfrm?.["a:chExt"]);
|
|
336
|
+
const extCx = Number(ext?.["@_cx"]);
|
|
337
|
+
const extCy = Number(ext?.["@_cy"]);
|
|
338
|
+
const chExtCx = Number(chExt?.["@_cx"]);
|
|
339
|
+
const chExtCy = Number(chExt?.["@_cy"]);
|
|
340
|
+
let groupScaleX =
|
|
341
|
+
Number.isFinite(extCx) && Number.isFinite(chExtCx) && chExtCx > 0
|
|
342
|
+
? extCx / chExtCx
|
|
343
|
+
: 1;
|
|
344
|
+
let groupScaleY =
|
|
345
|
+
Number.isFinite(extCy) && Number.isFinite(chExtCy) && chExtCy > 0
|
|
346
|
+
? extCy / chExtCy
|
|
347
|
+
: 1;
|
|
348
|
+
if (isOddQuarterTurn(Number(xfrm?.["@_rot"]))) {
|
|
349
|
+
[groupScaleX, groupScaleY] = [groupScaleY, groupScaleX];
|
|
350
|
+
}
|
|
351
|
+
return { x: parentScaleX * groupScaleX, y: parentScaleY * groupScaleY };
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* `a:xfrm/a:ext` always stores the shape's *unrotated* design-time width and
|
|
356
|
+
* height — `a:xfrm/@rot` (in 60,000ths of a degree) rotates it around its
|
|
357
|
+
* own center afterward. For a 90/270-degree turn, the shape's effective
|
|
358
|
+
* on-slide footprint has its width and height swapped relative to that
|
|
359
|
+
* declared size, so a rotated full-bleed photo can otherwise get treated as
|
|
360
|
+
* a small inset, or a portrait image get an inverted aspect ratio.
|
|
361
|
+
*/
|
|
362
|
+
function extFromSpPr(shapeNode: Record<string, unknown> | null): {
|
|
363
|
+
cx?: number;
|
|
364
|
+
cy?: number;
|
|
365
|
+
} {
|
|
366
|
+
const xfrm = record(record(shapeNode?.["p:spPr"])?.["a:xfrm"]);
|
|
367
|
+
const ext = record(xfrm?.["a:ext"]);
|
|
368
|
+
const cx = Number(ext?.["@_cx"]);
|
|
369
|
+
const cy = Number(ext?.["@_cy"]);
|
|
370
|
+
if (!Number.isFinite(cx) || cx <= 0 || !Number.isFinite(cy) || cy <= 0) {
|
|
371
|
+
return { cx: undefined, cy: undefined };
|
|
372
|
+
}
|
|
373
|
+
return isOddQuarterTurn(Number(xfrm?.["@_rot"]))
|
|
374
|
+
? { cx: cy, cy: cx }
|
|
375
|
+
: { cx, cy };
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function scaledShape(
|
|
379
|
+
embedId: string | undefined,
|
|
380
|
+
size: { cx?: number; cy?: number },
|
|
381
|
+
scaleX: number,
|
|
382
|
+
scaleY: number,
|
|
383
|
+
): PictureShape {
|
|
384
|
+
return {
|
|
385
|
+
embedId,
|
|
386
|
+
widthEmu: size.cx !== undefined ? size.cx * scaleX : undefined,
|
|
387
|
+
heightEmu: size.cy !== undefined ? size.cy * scaleY : undefined,
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* fast-xml-parser groups sibling elements by tag name, so a slide whose
|
|
393
|
+
* picture-bearing shapes alternate types on the page (e.g. `p:sp`, `p:pic`,
|
|
394
|
+
* `p:sp`) comes out of `collectPictureShapes` re-sorted by tag rather than
|
|
395
|
+
* by the order they actually appear — every `p:sp` gets visited before any
|
|
396
|
+
* `p:pic`, even if a `p:pic` came first in the file. Re-derive the true
|
|
397
|
+
* order from the raw XML text instead: `<a:blip r:embed="...">` occurrences
|
|
398
|
+
* appear in exactly document order regardless of nesting, so they can be
|
|
399
|
+
* used to restore the author's original front-to-back ordering (which
|
|
400
|
+
* matters because downstream rendering treats the first image as primary).
|
|
401
|
+
*/
|
|
402
|
+
function reorderByDocumentPosition(shapes: PictureShape[], xml: string): void {
|
|
403
|
+
const order: string[] = [];
|
|
404
|
+
const blipPattern = /<a:blip\b[^>]*\br:embed=(?:"([^"]+)"|'([^']+)')/g;
|
|
405
|
+
let match: RegExpExecArray | null;
|
|
406
|
+
while ((match = blipPattern.exec(xml))) order.push(match[1] ?? match[2]);
|
|
407
|
+
|
|
408
|
+
// The same relationship id can legitimately be reused across multiple
|
|
409
|
+
// placements (e.g. a repeated icon), so looking up a single fixed index
|
|
410
|
+
// per id would assign every reused shape the position of its first XML
|
|
411
|
+
// occurrence. Instead give each id a queue of its occurrence positions
|
|
412
|
+
// and consume one per shape, in the order shapes were collected.
|
|
413
|
+
const occurrences = new Map<string, number[]>();
|
|
414
|
+
order.forEach((embedId, index) => {
|
|
415
|
+
const queue = occurrences.get(embedId);
|
|
416
|
+
if (queue) queue.push(index);
|
|
417
|
+
else occurrences.set(embedId, [index]);
|
|
418
|
+
});
|
|
419
|
+
const positions = shapes.map((shape) => {
|
|
420
|
+
const queue = shape.embedId ? occurrences.get(shape.embedId) : undefined;
|
|
421
|
+
return queue && queue.length > 0 ? queue.shift()! : -1;
|
|
422
|
+
});
|
|
423
|
+
const sortedIndices = shapes.map((_, i) => i);
|
|
424
|
+
sortedIndices.sort((a, b) => positions[a] - positions[b]);
|
|
425
|
+
const sorted = sortedIndices.map((i) => shapes[i]);
|
|
426
|
+
shapes.splice(0, shapes.length, ...sorted);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/** Read the embed relationship id of a slide's background picture fill (`p:cSld/p:bg/p:bgPr/a:blipFill/a:blip`), if any. */
|
|
430
|
+
function extractBackgroundFillEmbedId(slide: unknown): string | undefined {
|
|
431
|
+
const root = record(slide);
|
|
432
|
+
const cSld = record(record(root?.["p:sld"])?.["p:cSld"] ?? root?.["p:cSld"]);
|
|
433
|
+
const bgPr = record(record(cSld?.["p:bg"])?.["p:bgPr"]);
|
|
434
|
+
const blip = record(record(bgPr?.["a:blipFill"])?.["a:blip"]);
|
|
435
|
+
return stringValue(blip?.["@_r:embed"]);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* Add the slide's background picture fill, if any, as a synthetic
|
|
440
|
+
* full-slide-sized shape. It's unshifted to the front rather than appended:
|
|
441
|
+
* downstream rendering only ever uses the first image in the list, and a
|
|
442
|
+
* background fill is the slide's base layer — the primary visual — so a
|
|
443
|
+
* smaller foreground picture (a logo, an icon) must not bump it out of that
|
|
444
|
+
* slot.
|
|
445
|
+
*/
|
|
446
|
+
function addBackgroundFillShape(
|
|
447
|
+
slide: unknown,
|
|
448
|
+
pictureShapes: PictureShape[],
|
|
449
|
+
slideWidthEmu: number | undefined,
|
|
450
|
+
slideHeightEmu: number | undefined,
|
|
451
|
+
): void {
|
|
452
|
+
const embedId = extractBackgroundFillEmbedId(slide);
|
|
453
|
+
if (!embedId) return;
|
|
454
|
+
pictureShapes.unshift({
|
|
455
|
+
embedId,
|
|
456
|
+
widthEmu: slideWidthEmu,
|
|
457
|
+
heightEmu: slideHeightEmu,
|
|
458
|
+
});
|
|
459
|
+
}
|
|
460
|
+
|
|
207
461
|
function runProperties(
|
|
208
462
|
value: Record<string, unknown> | null,
|
|
209
463
|
inherited: Omit<ParsedPptxTextRun, "content">,
|
|
@@ -177,9 +177,34 @@ workspace repo:
|
|
|
177
177
|
npx agent-native doctor --only no-env-credentials
|
|
178
178
|
```
|
|
179
179
|
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
180
|
+
## `resolveCredential` sees exactly one organization
|
|
181
|
+
|
|
182
|
+
`resolveCredential(key, { userEmail, orgId })` checks the user scope, then the
|
|
183
|
+
one `orgId` you pass, then the solo workspace. That is the whole search. It is
|
|
184
|
+
correct for a signed-in request, and wrong for two common cases:
|
|
185
|
+
|
|
186
|
+
- **The caller has no organization.** A cron, a scheduled job, or any CLI run
|
|
187
|
+
without a real member identity resolves no `orgId`, so only the user and solo
|
|
188
|
+
scopes are ever consulted — and a shared key is in neither.
|
|
189
|
+
- **The key was synced under a different organization.** `app_secrets` has no
|
|
190
|
+
scope visible to every org (`readAppSecret` is strict equality on
|
|
191
|
+
`(scope, scope_id, key)`), so a key the vault UI advertises as available to
|
|
192
|
+
every app is unreadable from any org except the one that ran the sync.
|
|
193
|
+
|
|
194
|
+
Both produce the same misleading "not set" that the split brain above produces,
|
|
195
|
+
which is why swapping `process.env` for `resolveCredential` can look like a fix
|
|
196
|
+
and change nothing. A workspace app should read shared keys through a resolver
|
|
197
|
+
that also sweeps the caller's other memberships and a designated vault org —
|
|
198
|
+
see `resolveConnectorSecret` and `AGENT_VAULT_ORG_ID` in the builder-workspace
|
|
199
|
+
repo for the shape, including the boot-time assertion that the deployment really
|
|
200
|
+
is single-tenant before a non-membership-gated fallback is safe.
|
|
201
|
+
|
|
202
|
+
**The doctor guard does not catch this second form** — it looks for
|
|
203
|
+
`process.env` reads, and `resolveCredential` is not one. Grep for
|
|
204
|
+
`resolveCredential` yourself and confirm each call sits somewhere a single-org
|
|
205
|
+
lookup is genuinely the right question. Findings that pair it with a
|
|
206
|
+
`?? process.env.KEY` fallback are the sanctioned deploy-level escape hatch; the
|
|
207
|
+
bugs are calls whose value can only live in another org's vault.
|
|
183
208
|
|
|
184
209
|
## HTTP routes
|
|
185
210
|
|
|
@@ -177,9 +177,34 @@ workspace repo:
|
|
|
177
177
|
npx agent-native doctor --only no-env-credentials
|
|
178
178
|
```
|
|
179
179
|
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
180
|
+
## `resolveCredential` sees exactly one organization
|
|
181
|
+
|
|
182
|
+
`resolveCredential(key, { userEmail, orgId })` checks the user scope, then the
|
|
183
|
+
one `orgId` you pass, then the solo workspace. That is the whole search. It is
|
|
184
|
+
correct for a signed-in request, and wrong for two common cases:
|
|
185
|
+
|
|
186
|
+
- **The caller has no organization.** A cron, a scheduled job, or any CLI run
|
|
187
|
+
without a real member identity resolves no `orgId`, so only the user and solo
|
|
188
|
+
scopes are ever consulted — and a shared key is in neither.
|
|
189
|
+
- **The key was synced under a different organization.** `app_secrets` has no
|
|
190
|
+
scope visible to every org (`readAppSecret` is strict equality on
|
|
191
|
+
`(scope, scope_id, key)`), so a key the vault UI advertises as available to
|
|
192
|
+
every app is unreadable from any org except the one that ran the sync.
|
|
193
|
+
|
|
194
|
+
Both produce the same misleading "not set" that the split brain above produces,
|
|
195
|
+
which is why swapping `process.env` for `resolveCredential` can look like a fix
|
|
196
|
+
and change nothing. A workspace app should read shared keys through a resolver
|
|
197
|
+
that also sweeps the caller's other memberships and a designated vault org —
|
|
198
|
+
see `resolveConnectorSecret` and `AGENT_VAULT_ORG_ID` in the builder-workspace
|
|
199
|
+
repo for the shape, including the boot-time assertion that the deployment really
|
|
200
|
+
is single-tenant before a non-membership-gated fallback is safe.
|
|
201
|
+
|
|
202
|
+
**The doctor guard does not catch this second form** — it looks for
|
|
203
|
+
`process.env` reads, and `resolveCredential` is not one. Grep for
|
|
204
|
+
`resolveCredential` yourself and confirm each call sits somewhere a single-org
|
|
205
|
+
lookup is genuinely the right question. Findings that pair it with a
|
|
206
|
+
`?? process.env.KEY` fallback are the sanctioned deploy-level escape hatch; the
|
|
207
|
+
bugs are calls whose value can only live in another org's vault.
|
|
183
208
|
|
|
184
209
|
## HTTP routes
|
|
185
210
|
|
|
@@ -177,9 +177,34 @@ workspace repo:
|
|
|
177
177
|
npx agent-native doctor --only no-env-credentials
|
|
178
178
|
```
|
|
179
179
|
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
180
|
+
## `resolveCredential` sees exactly one organization
|
|
181
|
+
|
|
182
|
+
`resolveCredential(key, { userEmail, orgId })` checks the user scope, then the
|
|
183
|
+
one `orgId` you pass, then the solo workspace. That is the whole search. It is
|
|
184
|
+
correct for a signed-in request, and wrong for two common cases:
|
|
185
|
+
|
|
186
|
+
- **The caller has no organization.** A cron, a scheduled job, or any CLI run
|
|
187
|
+
without a real member identity resolves no `orgId`, so only the user and solo
|
|
188
|
+
scopes are ever consulted — and a shared key is in neither.
|
|
189
|
+
- **The key was synced under a different organization.** `app_secrets` has no
|
|
190
|
+
scope visible to every org (`readAppSecret` is strict equality on
|
|
191
|
+
`(scope, scope_id, key)`), so a key the vault UI advertises as available to
|
|
192
|
+
every app is unreadable from any org except the one that ran the sync.
|
|
193
|
+
|
|
194
|
+
Both produce the same misleading "not set" that the split brain above produces,
|
|
195
|
+
which is why swapping `process.env` for `resolveCredential` can look like a fix
|
|
196
|
+
and change nothing. A workspace app should read shared keys through a resolver
|
|
197
|
+
that also sweeps the caller's other memberships and a designated vault org —
|
|
198
|
+
see `resolveConnectorSecret` and `AGENT_VAULT_ORG_ID` in the builder-workspace
|
|
199
|
+
repo for the shape, including the boot-time assertion that the deployment really
|
|
200
|
+
is single-tenant before a non-membership-gated fallback is safe.
|
|
201
|
+
|
|
202
|
+
**The doctor guard does not catch this second form** — it looks for
|
|
203
|
+
`process.env` reads, and `resolveCredential` is not one. Grep for
|
|
204
|
+
`resolveCredential` yourself and confirm each call sits somewhere a single-org
|
|
205
|
+
lookup is genuinely the right question. Findings that pair it with a
|
|
206
|
+
`?? process.env.KEY` fallback are the sanctioned deploy-level escape hatch; the
|
|
207
|
+
bugs are calls whose value can only live in another org's vault.
|
|
183
208
|
|
|
184
209
|
## HTTP routes
|
|
185
210
|
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
} from "@agent-native/core/server/request-context";
|
|
11
11
|
import { assertAccess } from "@agent-native/core/sharing";
|
|
12
12
|
import { eq } from "drizzle-orm";
|
|
13
|
+
import pLimit from "p-limit";
|
|
13
14
|
import { z } from "zod";
|
|
14
15
|
|
|
15
16
|
import { getDb, schema } from "../server/db/index.js";
|
|
@@ -127,19 +128,28 @@ export default defineAction({
|
|
|
127
128
|
if (detectedFormat === "pptx") {
|
|
128
129
|
const { parsePptx } =
|
|
129
130
|
await import("../server/handlers/import/pptx-parser.js");
|
|
130
|
-
const { convertToSlideHtml } =
|
|
131
|
-
await import("../server/handlers/import/html-converter.js");
|
|
132
131
|
const presentation = await parsePptx(fileBuffer);
|
|
133
132
|
const title = presentation.title || titleFromPath(filename);
|
|
134
133
|
|
|
135
134
|
if (importIntoDeck) {
|
|
136
135
|
if (!deckId) throw new Error("deckId is required to import into deck");
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
136
|
+
await assertAccess("deck", deckId, "editor");
|
|
137
|
+
const pptxOwnerEmail = getRequestUserEmail();
|
|
138
|
+
if (!pptxOwnerEmail) throw new Error("no authenticated user");
|
|
139
|
+
const pptxThemeFont = presentation.theme?.fonts?.[0];
|
|
140
|
+
const uploadLimit = pLimit(4);
|
|
141
|
+
const pptxResults = await Promise.all(
|
|
142
|
+
presentation.slides.map((slide, i) =>
|
|
143
|
+
uploadLimit(() =>
|
|
144
|
+
buildPptxSlide(slide, i, pptxOwnerEmail, pptxThemeFont),
|
|
145
|
+
),
|
|
146
|
+
),
|
|
147
|
+
);
|
|
148
|
+
const slides = pptxResults.map((r) => r.slide);
|
|
149
|
+
const imagesSkipped = pptxResults.reduce(
|
|
150
|
+
(total, r) => total + r.imageSkippedCount,
|
|
151
|
+
0,
|
|
152
|
+
);
|
|
143
153
|
await replaceDeckSlides(deckId, title, slides, "import-file:pptx");
|
|
144
154
|
return {
|
|
145
155
|
format: "pptx",
|
|
@@ -148,6 +158,7 @@ export default defineAction({
|
|
|
148
158
|
theme: presentation.theme,
|
|
149
159
|
deckId,
|
|
150
160
|
imported: true,
|
|
161
|
+
...(imagesSkipped > 0 ? { imagesSkipped } : {}),
|
|
151
162
|
};
|
|
152
163
|
}
|
|
153
164
|
|
|
@@ -452,6 +463,68 @@ function newSlideId(): string {
|
|
|
452
463
|
return `slide-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
|
|
453
464
|
}
|
|
454
465
|
|
|
466
|
+
// EMF/WMF (Windows metafiles) and TIFF are valid PPTX embed formats but
|
|
467
|
+
// browsers can't render them in an <img> tag — uploading and linking one
|
|
468
|
+
// would just produce a broken image icon.
|
|
469
|
+
const PPTX_BROWSER_RENDERABLE_IMAGE_MIME_TYPES = new Set([
|
|
470
|
+
"image/png",
|
|
471
|
+
"image/jpeg",
|
|
472
|
+
"image/gif",
|
|
473
|
+
"image/webp",
|
|
474
|
+
"image/svg+xml",
|
|
475
|
+
"image/bmp",
|
|
476
|
+
]);
|
|
477
|
+
|
|
478
|
+
async function buildPptxSlide(
|
|
479
|
+
slide: import("../server/handlers/import/pptx-parser.js").ParsedSlide,
|
|
480
|
+
slideIndex: number,
|
|
481
|
+
ownerEmail: string,
|
|
482
|
+
themeFont: string | undefined,
|
|
483
|
+
): Promise<{
|
|
484
|
+
slide: { id: string; content: string; layout: string; notes?: string };
|
|
485
|
+
imageSkippedCount: number;
|
|
486
|
+
}> {
|
|
487
|
+
const { convertToSlideHtml } =
|
|
488
|
+
await import("../server/handlers/import/html-converter.js");
|
|
489
|
+
const image = slide.images[0];
|
|
490
|
+
const uploadable =
|
|
491
|
+
image && PPTX_BROWSER_RENDERABLE_IMAGE_MIME_TYPES.has(image.mimeType)
|
|
492
|
+
? image
|
|
493
|
+
: undefined;
|
|
494
|
+
const imageUrl = uploadable
|
|
495
|
+
? await uploadFile({
|
|
496
|
+
data: Buffer.from(uploadable.data),
|
|
497
|
+
filename:
|
|
498
|
+
"pptx-import-" +
|
|
499
|
+
Date.now() +
|
|
500
|
+
"-s" +
|
|
501
|
+
slideIndex +
|
|
502
|
+
"-" +
|
|
503
|
+
uploadable.name,
|
|
504
|
+
mimeType: uploadable.mimeType,
|
|
505
|
+
ownerEmail,
|
|
506
|
+
recordAsset: false,
|
|
507
|
+
})
|
|
508
|
+
.then((result) => result?.url)
|
|
509
|
+
// A single slide's upload failing (network/API/rate-limit)
|
|
510
|
+
// shouldn't abort the whole deck replacement — fall back to a
|
|
511
|
+
// placeholder like an unsupported format would.
|
|
512
|
+
.catch(() => undefined)
|
|
513
|
+
: undefined;
|
|
514
|
+
return {
|
|
515
|
+
slide: {
|
|
516
|
+
id: newSlideId(),
|
|
517
|
+
content: convertToSlideHtml(slide, imageUrl, themeFont),
|
|
518
|
+
layout: slide.layoutHint ?? "content",
|
|
519
|
+
notes: slide.notes,
|
|
520
|
+
},
|
|
521
|
+
// Only the first image on a slide is ever uploaded, so every other
|
|
522
|
+
// image on that slide is unconditionally dropped too — not just the
|
|
523
|
+
// first one when it's unsupported.
|
|
524
|
+
imageSkippedCount: Math.max(0, slide.images.length - (imageUrl ? 1 : 0)),
|
|
525
|
+
};
|
|
526
|
+
}
|
|
527
|
+
|
|
455
528
|
function titleFromPath(filePath: string): string {
|
|
456
529
|
const base = path.basename(filePath, path.extname(filePath)).trim();
|
|
457
530
|
return base || "Imported File";
|