diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 00000000000..234ab0a33bd --- /dev/null +++ b/.mcp.json @@ -0,0 +1,14 @@ +{ + "mcpServers": { + "linear": { + "type": "http", + "url": "https://mcp.linear.app/mcp" + }, + "chrome-devtools": { + "type": "stdio", + "command": "npx", + "args": ["chrome-devtools-mcp@latest"], + "env": {} + } + } +} diff --git a/packages/base/card-api.gts b/packages/base/card-api.gts index cd6e05753c9..05f1dba129b 100644 --- a/packages/base/card-api.gts +++ b/packages/base/card-api.gts @@ -382,6 +382,12 @@ export type GetSearchResourceFuncOpts = { cards: CardDef[]; searchURL?: string; realms?: string[]; + queryErrors?: Array<{ + realm: string; + type: string; + message: string; + status?: number; + }>; }; }; export type GetSearchResourceFunc = ( diff --git a/packages/base/command.gts b/packages/base/command.gts index 03b32fe9fe4..d79224ba871 100644 --- a/packages/base/command.gts +++ b/packages/base/command.gts @@ -249,6 +249,11 @@ export class CreateAIAssistantRoomResult extends CardDef { @field roomId = contains(StringField); } +export class InviteUserToRoomInput extends CardDef { + @field roomId = contains(StringField); + @field userId = contains(StringField); +} + export class RegisterBotInput extends CardDef { @field username = contains(StringField); } @@ -363,7 +368,7 @@ export class ListingInstallResult extends CardDef { export class CreateListingPRInput extends CardDef { @field roomId = contains(StringField); @field realm = contains(RealmField); - @field listing = linksTo(CardDef); + @field listingId = contains(StringField); } export class CreateListingPRResult extends CardDef { @@ -374,6 +379,12 @@ export class CreateListingPRResult extends CardDef { @field prNumber = contains(NumberField); } +export class CreateListingPRRequestInput extends CardDef { + @field roomId = contains(StringField); + @field realm = contains(RealmField); + @field listingId = contains(StringField); +} + export class ListingCreateInput extends CardDef { @field openCardId = contains(StringField); @field codeRef = contains(CodeRefField); @@ -419,6 +430,12 @@ export class GetEventsFromRoomResult extends CardDef { @field matrixEvents = containsMany(JsonField); } +export class SendBotTriggerEventInput extends CardDef { + @field roomId = contains(StringField); + @field type = contains(StringField); + @field input = contains(JsonField); +} + export class PreviewFormatInput extends CardDef { @field cardId = contains(StringField); @field format = contains(StringField); diff --git a/packages/base/gif-image-def.gts b/packages/base/gif-image-def.gts new file mode 100644 index 00000000000..488aef9d895 --- /dev/null +++ b/packages/base/gif-image-def.gts @@ -0,0 +1,30 @@ +import { byteStreamToUint8Array } from '@cardstack/runtime-common'; +import { ImageDef } from './image-file-def'; +import { type ByteStream, type SerializedFile } from './file-api'; +import { extractGifDimensions } from './gif-meta-extractor'; + +export class GifDef extends ImageDef { + static displayName = 'GIF Image'; + + static async extractAttributes( + url: string, + getStream: () => Promise, + options: { contentHash?: string } = {}, + ): Promise> { + let bytesPromise: Promise | undefined; + let memoizedStream = async () => { + bytesPromise ??= byteStreamToUint8Array(await getStream()); + return bytesPromise; + }; + + let base = await super.extractAttributes(url, memoizedStream, options); + let bytes = await memoizedStream(); + let { width, height } = extractGifDimensions(bytes); + + return { + ...base, + width, + height, + }; + } +} diff --git a/packages/base/gif-meta-extractor.ts b/packages/base/gif-meta-extractor.ts new file mode 100644 index 00000000000..50557981b2c --- /dev/null +++ b/packages/base/gif-meta-extractor.ts @@ -0,0 +1,45 @@ +import { FileContentMismatchError } from './file-api'; + +// GIF files start with either "GIF87a" or "GIF89a" (6 bytes) +const GIF87A_SIGNATURE = new Uint8Array([0x47, 0x49, 0x46, 0x38, 0x37, 0x61]); +const GIF89A_SIGNATURE = new Uint8Array([0x47, 0x49, 0x46, 0x38, 0x39, 0x61]); + +// Minimum bytes needed: 6 (signature) + 4 (width + height) +const MIN_BYTES = 10; + +function validateGifSignature(bytes: Uint8Array): void { + if (bytes.length < 6) { + throw new FileContentMismatchError( + 'File is too small to be a valid GIF image', + ); + } + + let isGif87a = GIF87A_SIGNATURE.every((b, i) => bytes[i] === b); + let isGif89a = GIF89A_SIGNATURE.every((b, i) => bytes[i] === b); + + if (!isGif87a && !isGif89a) { + throw new FileContentMismatchError( + 'File does not have a valid GIF signature', + ); + } +} + +export function extractGifDimensions(bytes: Uint8Array): { + width: number; + height: number; +} { + validateGifSignature(bytes); + + if (bytes.length < MIN_BYTES) { + throw new FileContentMismatchError( + 'GIF file is too small to contain image dimensions', + ); + } + + // Width is at bytes 6-7, height at 8-9 (little-endian uint16) + let view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + let width = view.getUint16(6, true); + let height = view.getUint16(8, true); + + return { width, height }; +} diff --git a/packages/base/image-file-def.gts b/packages/base/image-file-def.gts new file mode 100644 index 00000000000..e061ad7747c --- /dev/null +++ b/packages/base/image-file-def.gts @@ -0,0 +1,207 @@ +import NumberField from './number'; +import { BaseDefComponent, Component, contains, field } from './card-api'; +import { FileDef } from './file-api'; + +class Isolated extends Component { + +} + +class Atom extends Component { + +} + +class Embedded extends Component { + +} + +class Fitted extends Component { + get backgroundImageStyle() { + if (this.args.model.url) { + return `background-image: url(${this.args.model.url});`; + } + return undefined; + } + + +} + +export class ImageDef extends FileDef { + static displayName = 'Image'; + + @field width = contains(NumberField); + @field height = contains(NumberField); + + static isolated: BaseDefComponent = Isolated; + static embedded: BaseDefComponent = Embedded; + static atom: BaseDefComponent = Atom; + static fitted: BaseDefComponent = Fitted; +} diff --git a/packages/base/jpg-image-def.gts b/packages/base/jpg-image-def.gts new file mode 100644 index 00000000000..60af584407d --- /dev/null +++ b/packages/base/jpg-image-def.gts @@ -0,0 +1,30 @@ +import { byteStreamToUint8Array } from '@cardstack/runtime-common'; +import { ImageDef } from './image-file-def'; +import { type ByteStream, type SerializedFile } from './file-api'; +import { extractJpgDimensions } from './jpg-meta-extractor'; + +export class JpgDef extends ImageDef { + static displayName = 'JPEG Image'; + + static async extractAttributes( + url: string, + getStream: () => Promise, + options: { contentHash?: string } = {}, + ): Promise> { + let bytesPromise: Promise | undefined; + let memoizedStream = async () => { + bytesPromise ??= byteStreamToUint8Array(await getStream()); + return bytesPromise; + }; + + let base = await super.extractAttributes(url, memoizedStream, options); + let bytes = await memoizedStream(); + let { width, height } = extractJpgDimensions(bytes); + + return { + ...base, + width, + height, + }; + } +} diff --git a/packages/base/jpg-meta-extractor.ts b/packages/base/jpg-meta-extractor.ts new file mode 100644 index 00000000000..908ede714fb --- /dev/null +++ b/packages/base/jpg-meta-extractor.ts @@ -0,0 +1,109 @@ +import { FileContentMismatchError } from './file-api'; + +// JPEG files start with SOI marker: FF D8 +const JPEG_SOI = new Uint8Array([0xff, 0xd8]); + +// Minimum bytes: 2 (SOI) + 2 (marker) + 2 (length) + 5 (precision + height + width) +const MIN_BYTES = 11; + +// SOF markers that contain image dimensions +const SOF_MARKERS = new Set([ + 0xc0, // SOF0 — Baseline DCT + 0xc1, // SOF1 — Extended Sequential DCT + 0xc2, // SOF2 — Progressive DCT + 0xc3, // SOF3 — Lossless (Sequential) + 0xc5, // SOF5 — Differential Sequential DCT + 0xc6, // SOF6 — Differential Progressive DCT + 0xc7, // SOF7 — Differential Lossless (Sequential) + 0xc9, // SOF9 — Extended Sequential DCT (Arithmetic) + 0xca, // SOF10 — Progressive DCT (Arithmetic) + 0xcb, // SOF11 — Lossless (Sequential) (Arithmetic) + 0xcd, // SOF13 — Differential Sequential DCT (Arithmetic) + 0xce, // SOF14 — Differential Progressive DCT (Arithmetic) + 0xcf, // SOF15 — Differential Lossless (Arithmetic) +]); + +function validateJpegSignature(bytes: Uint8Array): void { + if (bytes.length < JPEG_SOI.length) { + throw new FileContentMismatchError( + 'File is too small to be a valid JPEG image', + ); + } + if (bytes[0] !== JPEG_SOI[0] || bytes[1] !== JPEG_SOI[1]) { + throw new FileContentMismatchError( + 'File does not have a valid JPEG signature', + ); + } +} + +export function extractJpgDimensions(bytes: Uint8Array): { + width: number; + height: number; +} { + validateJpegSignature(bytes); + + if (bytes.length < MIN_BYTES) { + throw new FileContentMismatchError( + 'JPEG file is too small to contain frame dimensions', + ); + } + + let view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + + // Scan for SOF marker starting after SOI (offset 2) + let offset = 2; + while (offset < bytes.length - 1) { + // Each marker starts with 0xFF + if (bytes[offset] !== 0xff) { + throw new FileContentMismatchError( + 'JPEG file has invalid marker structure', + ); + } + + // Skip padding 0xFF bytes + while (offset < bytes.length - 1 && bytes[offset + 1] === 0xff) { + offset++; + } + + let marker = bytes[offset + 1]!; + offset += 2; + + // SOS (Start of Scan) — no more markers with length fields follow + if (marker === 0xda) { + break; + } + + // Markers without a length field (standalone markers) + if (marker === 0xd8 || marker === 0xd9 || (marker >= 0xd0 && marker <= 0xd7)) { + continue; + } + + // Read segment length (includes the 2 length bytes but not the marker) + if (offset + 2 > bytes.length) { + break; + } + let segmentLength = view.getUint16(offset); + + if (SOF_MARKERS.has(marker)) { + // SOF segment layout after length: + // 1 byte — precision + // 2 bytes — height (big-endian) + // 2 bytes — width (big-endian) + if (offset + 2 + 5 > bytes.length) { + throw new FileContentMismatchError( + 'JPEG SOF segment is truncated', + ); + } + let height = view.getUint16(offset + 2 + 1); + let width = view.getUint16(offset + 2 + 3); + return { width, height }; + } + + // Skip to next marker + offset += segmentLength; + } + + throw new FileContentMismatchError( + 'JPEG file does not contain a SOF marker with image dimensions', + ); +} diff --git a/packages/base/png-image-def.gts b/packages/base/png-image-def.gts new file mode 100644 index 00000000000..4801be96c8d --- /dev/null +++ b/packages/base/png-image-def.gts @@ -0,0 +1,30 @@ +import { byteStreamToUint8Array } from '@cardstack/runtime-common'; +import { ImageDef } from './image-file-def'; +import { type ByteStream, type SerializedFile } from './file-api'; +import { extractPngDimensions } from './png-meta-extractor'; + +export class PngDef extends ImageDef { + static displayName = 'PNG Image'; + + static async extractAttributes( + url: string, + getStream: () => Promise, + options: { contentHash?: string } = {}, + ): Promise> { + let bytesPromise: Promise | undefined; + let memoizedStream = async () => { + bytesPromise ??= byteStreamToUint8Array(await getStream()); + return bytesPromise; + }; + + let base = await super.extractAttributes(url, memoizedStream, options); + let bytes = await memoizedStream(); + let { width, height } = extractPngDimensions(bytes); + + return { + ...base, + width, + height, + }; + } +} diff --git a/packages/base/png-meta-extractor.ts b/packages/base/png-meta-extractor.ts new file mode 100644 index 00000000000..08d476eac30 --- /dev/null +++ b/packages/base/png-meta-extractor.ts @@ -0,0 +1,42 @@ +import { FileContentMismatchError } from './file-api'; + +// PNG 8-byte magic signature +const PNG_SIGNATURE = new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]); + +// Minimum bytes needed: 8 (signature) + 8 (IHDR chunk header) + 8 (width + height) +const MIN_BYTES = 24; + +function validatePngSignature(bytes: Uint8Array): void { + if (bytes.length < PNG_SIGNATURE.length) { + throw new FileContentMismatchError( + 'File is too small to be a valid PNG image', + ); + } + for (let i = 0; i < PNG_SIGNATURE.length; i++) { + if (bytes[i] !== PNG_SIGNATURE[i]) { + throw new FileContentMismatchError( + 'File does not have a valid PNG signature', + ); + } + } +} + +export function extractPngDimensions(bytes: Uint8Array): { + width: number; + height: number; +} { + validatePngSignature(bytes); + + if (bytes.length < MIN_BYTES) { + throw new FileContentMismatchError( + 'PNG file is too small to contain IHDR chunk', + ); + } + + // Width is at bytes 16-19, height at 20-23 (big-endian uint32) + let view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + let width = view.getUint32(16); + let height = view.getUint32(20); + + return { width, height }; +} diff --git a/packages/base/query-field-support.ts b/packages/base/query-field-support.ts index b96998c927b..7db2f9e95ad 100644 --- a/packages/base/query-field-support.ts +++ b/packages/base/query-field-support.ts @@ -31,6 +31,12 @@ interface QueryFieldState { seedSearchURL?: string | null; seedRecords?: CardDef[]; seedRealms?: string[]; + seedErrors?: Array<{ + realm: string; + type: string; + message: string; + status?: number; + }>; searchResource?: StoreSearchResource; } @@ -87,6 +93,7 @@ export function ensureQueryFieldSearchResource( cards: seedRecords, searchURL: seedSearchURL ?? undefined, realms: fieldState?.seedRealms, + queryErrors: fieldState?.seedErrors, } : undefined, }, @@ -261,6 +268,8 @@ export function captureQueryFieldSeedData( fieldState.seedRealms = fieldState.seedSearchURL ? [parseSearchURL(new URL(fieldState.seedSearchURL)).realm.href] : []; + fieldState.seedErrors = + (relationship?.meta as any)?.errors ?? undefined; } function resolveQueryAndRealm( diff --git a/packages/base/svg-image-def.gts b/packages/base/svg-image-def.gts new file mode 100644 index 00000000000..873cb177869 --- /dev/null +++ b/packages/base/svg-image-def.gts @@ -0,0 +1,30 @@ +import { byteStreamToUint8Array } from '@cardstack/runtime-common'; +import { ImageDef } from './image-file-def'; +import { type ByteStream, type SerializedFile } from './file-api'; +import { extractSvgDimensions } from './svg-meta-extractor'; + +export class SvgDef extends ImageDef { + static displayName = 'SVG Image'; + + static async extractAttributes( + url: string, + getStream: () => Promise, + options: { contentHash?: string } = {}, + ): Promise> { + let bytesPromise: Promise | undefined; + let memoizedStream = async () => { + bytesPromise ??= byteStreamToUint8Array(await getStream()); + return bytesPromise; + }; + + let base = await super.extractAttributes(url, memoizedStream, options); + let bytes = await memoizedStream(); + let { width, height } = extractSvgDimensions(bytes); + + return { + ...base, + width, + height, + }; + } +} diff --git a/packages/base/svg-meta-extractor.ts b/packages/base/svg-meta-extractor.ts new file mode 100644 index 00000000000..1cc30337582 --- /dev/null +++ b/packages/base/svg-meta-extractor.ts @@ -0,0 +1,58 @@ +import { FileContentMismatchError } from './file-api'; + +/** + * Extract width and height from an SVG file's bytes. + * + * Looks for explicit `width`/`height` attributes on the root `` element + * first, then falls back to the `viewBox` attribute. Only absolute numeric + * values (with optional "px" suffix) are accepted for width/height attributes; + * percentage or other relative units are ignored in favour of viewBox. + */ +export function extractSvgDimensions(bytes: Uint8Array): { + width: number; + height: number; +} { + let text: string; + try { + text = new TextDecoder('utf-8', { fatal: true }).decode(bytes); + } catch { + throw new FileContentMismatchError( + 'File cannot be decoded as UTF-8 text', + ); + } + + // Find the opening tag (case-insensitive, may span multiple lines) + let svgTagMatch = text.match(/]*>/is); + if (!svgTagMatch) { + throw new FileContentMismatchError( + 'File does not contain an SVG root element', + ); + } + let svgTag = svgTagMatch[0]; + + // Try explicit width/height attributes first (numeric values, optional "px") + let widthAttr = svgTag.match(/\bwidth\s*=\s*["']?\s*(\d+(?:\.\d+)?)\s*(?:px)?\s*["']?/i); + let heightAttr = svgTag.match(/\bheight\s*=\s*["']?\s*(\d+(?:\.\d+)?)\s*(?:px)?\s*["']?/i); + + if (widthAttr && heightAttr) { + return { + width: Math.round(parseFloat(widthAttr[1]!)), + height: Math.round(parseFloat(heightAttr[1]!)), + }; + } + + // Fall back to viewBox="minX minY width height" + let viewBoxAttr = svgTag.match( + /\bviewBox\s*=\s*["']\s*[\d.]+[\s,]+[\d.]+[\s,]+([\d.]+)[\s,]+([\d.]+)\s*["']/i, + ); + if (viewBoxAttr) { + return { + width: Math.round(parseFloat(viewBoxAttr[1]!)), + height: Math.round(parseFloat(viewBoxAttr[2]!)), + }; + } + + throw new FileContentMismatchError( + 'SVG does not specify width/height or viewBox dimensions', + ); +} diff --git a/packages/base/webp-image-def.gts b/packages/base/webp-image-def.gts new file mode 100644 index 00000000000..66b4192a681 --- /dev/null +++ b/packages/base/webp-image-def.gts @@ -0,0 +1,30 @@ +import { byteStreamToUint8Array } from '@cardstack/runtime-common'; +import { ImageDef } from './image-file-def'; +import { type ByteStream, type SerializedFile } from './file-api'; +import { extractWebpDimensions } from './webp-meta-extractor'; + +export class WebpDef extends ImageDef { + static displayName = 'WebP Image'; + + static async extractAttributes( + url: string, + getStream: () => Promise, + options: { contentHash?: string } = {}, + ): Promise> { + let bytesPromise: Promise | undefined; + let memoizedStream = async () => { + bytesPromise ??= byteStreamToUint8Array(await getStream()); + return bytesPromise; + }; + + let base = await super.extractAttributes(url, memoizedStream, options); + let bytes = await memoizedStream(); + let { width, height } = extractWebpDimensions(bytes); + + return { + ...base, + width, + height, + }; + } +} diff --git a/packages/base/webp-meta-extractor.ts b/packages/base/webp-meta-extractor.ts new file mode 100644 index 00000000000..63034e7d3a4 --- /dev/null +++ b/packages/base/webp-meta-extractor.ts @@ -0,0 +1,108 @@ +import { FileContentMismatchError } from './file-api'; + +// WebP files start with "RIFF" (4 bytes) + file size (4 bytes) + "WEBP" (4 bytes) +const RIFF_SIGNATURE = new Uint8Array([0x52, 0x49, 0x46, 0x46]); // "RIFF" +const WEBP_SIGNATURE = new Uint8Array([0x57, 0x45, 0x42, 0x50]); // "WEBP" + +// Minimum bytes: 12 (RIFF header) + 4 (chunk FourCC) + enough for dimensions +const MIN_BYTES = 30; + +function validateWebpSignature(bytes: Uint8Array): void { + if (bytes.length < 12) { + throw new FileContentMismatchError( + 'File is too small to be a valid WebP image', + ); + } + let isRiff = RIFF_SIGNATURE.every((b, i) => bytes[i] === b); + let isWebp = WEBP_SIGNATURE.every((b, i) => bytes[i + 8] === b); + + if (!isRiff || !isWebp) { + throw new FileContentMismatchError( + 'File does not have a valid WebP signature', + ); + } +} + +export function extractWebpDimensions(bytes: Uint8Array): { + width: number; + height: number; +} { + validateWebpSignature(bytes); + + if (bytes.length < MIN_BYTES) { + throw new FileContentMismatchError( + 'WebP file is too small to contain image dimensions', + ); + } + + // Chunk FourCC starts at byte 12 + let chunkFourCC = String.fromCharCode( + bytes[12]!, + bytes[13]!, + bytes[14]!, + bytes[15]!, + ); + + let view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + + if (chunkFourCC === 'VP8 ') { + // Lossy WebP — VP8 bitstream + // Frame tag starts at offset 20 (after RIFF header + "VP8 " + chunk size) + // Bytes 20-22: frame tag (3 bytes) + // Bytes 23-25: start code 0x9D 0x01 0x2A + // Bytes 26-27: width (little-endian, lower 14 bits) + // Bytes 28-29: height (little-endian, lower 14 bits) + if (bytes.length < 30) { + throw new FileContentMismatchError( + 'VP8 chunk is too small to contain dimensions', + ); + } + let width = view.getUint16(26, true) & 0x3fff; + let height = view.getUint16(28, true) & 0x3fff; + return { width, height }; + } + + if (chunkFourCC === 'VP8L') { + // Lossless WebP + // Byte 21: signature byte 0x2F + // Bytes 21-24: bitstream header containing width and height + // Width: bits 0-13 of the 32-bit LE value at offset 21 (after signature byte) + // Height: bits 14-27 + if (bytes.length < 25) { + throw new FileContentMismatchError( + 'VP8L chunk is too small to contain dimensions', + ); + } + let signature = bytes[20]; + if (signature !== 0x2f) { + throw new FileContentMismatchError( + 'VP8L chunk has invalid signature byte', + ); + } + let bits = view.getUint32(21, true); + let width = (bits & 0x3fff) + 1; + let height = ((bits >> 14) & 0x3fff) + 1; + return { width, height }; + } + + if (chunkFourCC === 'VP8X') { + // Extended WebP — VP8X chunk contains canvas dimensions + // Bytes 20-23: flags (4 bytes) + // Bytes 24-26: canvas width minus one (24-bit LE) + // Bytes 27-29: canvas height minus one (24-bit LE) + if (bytes.length < 30) { + throw new FileContentMismatchError( + 'VP8X chunk is too small to contain dimensions', + ); + } + let width = + (bytes[24]! | (bytes[25]! << 8) | (bytes[26]! << 16)) + 1; + let height = + (bytes[27]! | (bytes[28]! << 8) | (bytes[29]! << 16)) + 1; + return { width, height }; + } + + throw new FileContentMismatchError( + `WebP file has unrecognized chunk type: ${chunkFourCC}`, + ); +} diff --git a/packages/billing/stripe-webhook-handlers/index.ts b/packages/billing/stripe-webhook-handlers/index.ts index 0ee1145f524..b3a36bc9b4d 100644 --- a/packages/billing/stripe-webhook-handlers/index.ts +++ b/packages/billing/stripe-webhook-handlers/index.ts @@ -37,6 +37,8 @@ export type StripeInvoicePaymentSucceededWebhookEvent = StripeEvent & { data: Array<{ amount: number; description: string; + type?: string; + proration?: boolean; price: { product: string; }; diff --git a/packages/billing/stripe-webhook-handlers/payment-succeeded.ts b/packages/billing/stripe-webhook-handlers/payment-succeeded.ts index 9b13df47112..d1870055820 100644 --- a/packages/billing/stripe-webhook-handlers/payment-succeeded.ts +++ b/packages/billing/stripe-webhook-handlers/payment-succeeded.ts @@ -26,6 +26,32 @@ import type { PgAdapter } from '@cardstack/postgres'; import { TransactionManager } from '@cardstack/postgres'; import { ProrationCalculator } from '../proration-calculator'; +function getInvoiceSubscriptionPeriod( + event: StripeInvoicePaymentSucceededWebhookEvent, + planStripeId: string, +) { + let lineItem = event.data.object.lines.data.find( + (line) => + line.amount >= 0 && + line.type === 'subscription' && + line.proration !== true && + line.price?.product === planStripeId && + line.period?.start && + line.period?.end, + ); + + if (lineItem?.period) { + return { + periodStart: lineItem.period.start, + periodEnd: lineItem.period.end, + }; + } + + throw new Error( + `Expected subscription period to be present in payment succeeded webhook event (event id: ${event.id}, invoice id: ${event.data.object.id}, subscription id: ${event.data.object.subscription}, plan stripe id: ${planStripeId})`, + ); +} + export async function handlePaymentSucceeded( dbAdapter: DBAdapter, event: StripeInvoicePaymentSucceededWebhookEvent, @@ -49,6 +75,11 @@ export async function handlePaymentSucceeded( throw new Error(`No plan found for product id: ${productId}`); } + let { periodStart, periodEnd } = getInvoiceSubscriptionPeriod( + event, + plan.stripePlanId, + ); + // When user first signs up for a plan, our checkout.session.completed handler takes care of assigning the user a stripe customer id. // Stripe customer id is needed so that we can recognize the user when their subscription is renewed, or canceled. // The mentioned webhook should be sent before this one, but if there are any network or processing delays, @@ -75,12 +106,18 @@ export async function handlePaymentSucceeded( user, plan, creditAllowance: plan.creditsIncluded, - periodStart: event.data.object.period_start, - periodEnd: event.data.object.period_end, + periodStart, + periodEnd, event, }); } else if (billingReason === 'subscription_cycle') { - await createSubscriptionCycle(dbAdapter, user, plan, event); + await createSubscriptionCycle( + dbAdapter, + user, + plan, + periodStart, + periodEnd, + ); } else if (billingReason === 'subscription_update') { await updateSubscription(dbAdapter, user, plan, event); } @@ -193,8 +230,9 @@ async function updateSubscription( async function createSubscriptionCycle( dbAdapter: DBAdapter, user: { id: string }, - plan: { creditsIncluded: number }, - event: StripeInvoicePaymentSucceededWebhookEvent, + plan: Plan, + periodStart: number, + periodEnd: number, ) { let currentActiveSubscription = await getCurrentActiveSubscription( dbAdapter, @@ -226,8 +264,8 @@ async function createSubscriptionCycle( let newSubscriptionCycle = await insertSubscriptionCycle(dbAdapter, { subscriptionId: currentActiveSubscription.id, - periodStart: event.data.object.period_start, - periodEnd: event.data.object.period_end, + periodStart, + periodEnd, }); await addToCreditsLedger(dbAdapter, { diff --git a/packages/catalog-realm/catalog-app/listing/listing.gts b/packages/catalog-realm/catalog-app/listing/listing.gts index d7de40255db..159791951a7 100644 --- a/packages/catalog-realm/catalog-app/listing/listing.gts +++ b/packages/catalog-realm/catalog-app/listing/listing.gts @@ -677,7 +677,7 @@ export class Listing extends CardDef { label: 'Make a PR', action: async () => { await new CreateListingPRCommand(commandContext).execute({ - listing: this, + listingId: this.id, realm: this[realmURL]!.href, }); }, diff --git a/packages/experiments-realm/PngDefPlayground/ae41b3ed-512e-4978-8925-1aad8a3d3d93.json b/packages/experiments-realm/PngDefPlayground/ae41b3ed-512e-4978-8925-1aad8a3d3d93.json new file mode 100644 index 00000000000..e030c52e50f --- /dev/null +++ b/packages/experiments-realm/PngDefPlayground/ae41b3ed-512e-4978-8925-1aad8a3d3d93.json @@ -0,0 +1,53 @@ +{ + "data": { + "type": "card", + "attributes": { + "title": "Image Gallery Demo", + "description": "Showcasing ImageDef/PngDef with various images from the experiments realm" + }, + "relationships": { + "featuredImage": { + "links": { + "self": "./mango.png" + }, + "data": { + "type": "file-meta", + "id": "./mango.png" + } + }, + "gallery.0": { + "links": { + "self": "./mango.png" + }, + "data": { + "type": "file-meta", + "id": "./mango.png" + } + }, + "gallery.1": { + "links": { + "self": "../logo.png" + }, + "data": { + "type": "file-meta", + "id": "../logo.png" + } + }, + "gallery.2": { + "links": { + "self": "../FileLinksExample/mango.png" + }, + "data": { + "type": "file-meta", + "id": "../FileLinksExample/mango.png" + } + } + }, + "meta": { + "adoptsFrom": { + "module": "../png-def-playground", + "name": "PngDefPlayground" + } + } + } +} diff --git a/packages/experiments-realm/PngDefPlayground/mango-demo.json b/packages/experiments-realm/PngDefPlayground/mango-demo.json new file mode 100644 index 00000000000..e735492f7ec --- /dev/null +++ b/packages/experiments-realm/PngDefPlayground/mango-demo.json @@ -0,0 +1,35 @@ +{ + "data": { + "type": "card", + "attributes": { + "title": "PNG Image Playground", + "description": "Demonstrating PngDef capabilities including automatic dimension extraction and various display formats" + }, + "relationships": { + "featuredImage": { + "links": { + "self": "./mango.png" + }, + "data": { + "type": "file-meta", + "id": "./mango.png" + } + }, + "gallery.0": { + "links": { + "self": "./mango.png" + }, + "data": { + "type": "file-meta", + "id": "./mango.png" + } + } + }, + "meta": { + "adoptsFrom": { + "module": "../png-def-playground", + "name": "PngDefPlayground" + } + } + } +} diff --git a/packages/experiments-realm/PngDefPlayground/mango.png b/packages/experiments-realm/PngDefPlayground/mango.png new file mode 100644 index 00000000000..0e1efd55f34 Binary files /dev/null and b/packages/experiments-realm/PngDefPlayground/mango.png differ diff --git a/packages/experiments-realm/png-def-playground.gts b/packages/experiments-realm/png-def-playground.gts new file mode 100644 index 00000000000..c4da4d77f2a --- /dev/null +++ b/packages/experiments-realm/png-def-playground.gts @@ -0,0 +1,249 @@ +import { + CardDef, + Component, + StringField, + contains, + field, + linksTo, + linksToMany, +} from 'https://cardstack.com/base/card-api'; +import { gt } from '@cardstack/boxel-ui/helpers'; +import { ImageDef } from 'https://cardstack.com/base/image-file-def'; + +/** + * Playground card for demonstrating ImageDef/PngDef capabilities. + * + * This card shows how ImageDef (and its subclass PngDef): + * - Automatically extracts image dimensions (width, height) + * - Renders images in different formats (isolated, embedded, atom, fitted) + * - Works with linksTo and linksToMany fields + */ +export class PngDefPlayground extends CardDef { + static displayName = 'Image Def Playground'; + + @field title = contains(StringField); + @field description = contains(StringField); + + // Single image link (accepts ImageDef or PngDef) + @field featuredImage = linksTo(ImageDef); + + // Multiple image links + @field gallery = linksToMany(ImageDef); + + static isolated = class Isolated extends Component { +

{{@model.title}}

+ {{#if @model.description}} +

{{@model.description}}

+ {{/if}} + + + + +
+

Format Comparison

+ {{#if @model.featuredImage}} +
+
+

Embedded

+
+ <@fields.featuredImage @format='embedded' /> +
+
+ +
+

Atom

+
+ <@fields.featuredImage @format='atom' /> +
+
+ +
+

Fitted

+
+ <@fields.featuredImage @format='fitted' /> +
+
+
+ {{else}} +

Link a featured image to see format + comparison

+ {{/if}} +
+ + + + + + + }; +} diff --git a/packages/host/app/commands/create-listing-pr.ts b/packages/host/app/commands/create-listing-pr.ts index 23cd4462dd6..9c5bd7bff45 100644 --- a/packages/host/app/commands/create-listing-pr.ts +++ b/packages/host/app/commands/create-listing-pr.ts @@ -65,14 +65,14 @@ export default class CreateListingPRCommand extends HostBaseCommand< return CreateListingPRResult; } - requireInputFields = ['realm', 'listing']; + requireInputFields = ['realm', 'listingId']; protected async run( input: BaseCommandModule.CreateListingPRInput, ): Promise { await this.matrixService.ready; - let { listing: listingInput, realm } = input; + let { listingId, realm } = input; let realmUrls = this.realmServer.availableRealmURLs; let realmUrl = new RealmPaths(new URL(realm)).url; @@ -80,8 +80,15 @@ export default class CreateListingPRCommand extends HostBaseCommand< throw new Error(`Invalid realm: ${realmUrl}`); } + if (!listingId) { + throw new Error('Missing listingId for CreateListingPR'); + } + // Listing type is from catalog; base command cannot express that type - const listing = listingInput as Listing; + const listing = (await this.store.get(listingId)) as Listing; + if (!listing) { + throw new Error(`Listing not found: ${listingId}`); + } const snapshotId = uuidv4(); const branch = this.generateBranchName(listing, snapshotId); diff --git a/packages/host/app/commands/index.ts b/packages/host/app/commands/index.ts index b7f9cc430c6..79dc27836a7 100644 --- a/packages/host/app/commands/index.ts +++ b/packages/host/app/commands/index.ts @@ -19,6 +19,7 @@ import * as GenerateThemeExampleCommandModule from './generate-theme-example'; import * as GetAllRealmMetasCommandModule from './get-all-realm-metas'; import * as GetCardCommandModule from './get-card'; import * as GetEventsFromRoomCommandModule from './get-events-from-room'; +import * as InviteUserToRoomCommandModule from './invite-user-to-room'; import * as LintAndFixCommandModule from './lint-and-fix'; import * as ListingBuildCommandModule from './listing-action-build'; import * as ListingInitCommandModule from './listing-action-init'; @@ -117,6 +118,10 @@ export function shimHostCommands(virtualNetwork: VirtualNetwork) { '@cardstack/boxel-host/commands/get-events-from-room', GetEventsFromRoomCommandModule, ); + virtualNetwork.shimModule( + '@cardstack/boxel-host/commands/invite-user-to-room', + InviteUserToRoomCommandModule, + ); virtualNetwork.shimModule( '@cardstack/boxel-host/commands/lint-and-fix', LintAndFixCommandModule, @@ -332,6 +337,7 @@ export const HostCommandClasses: (typeof HostBaseCommand)[] = [ GetAllRealmMetasCommandModule.default, GetCardCommandModule.default, GetEventsFromRoomCommandModule.default, + InviteUserToRoomCommandModule.default, LintAndFixCommandModule.default, ListingBuildCommandModule.default, ListingInitCommandModule.default, diff --git a/packages/host/app/commands/invite-user-to-room.ts b/packages/host/app/commands/invite-user-to-room.ts new file mode 100644 index 00000000000..8804fb894e9 --- /dev/null +++ b/packages/host/app/commands/invite-user-to-room.ts @@ -0,0 +1,34 @@ +import { service } from '@ember/service'; + +import type * as BaseCommandModule from 'https://cardstack.com/base/command'; + +import HostBaseCommand from '../lib/host-base-command'; + +import type MatrixService from '../services/matrix-service'; + +export default class InviteUserToRoomCommand extends HostBaseCommand< + typeof BaseCommandModule.InviteUserToRoomInput +> { + @service declare private matrixService: MatrixService; + + static actionVerb = 'Invite'; + + async getInputType() { + let commandModule = await this.loadCommandModule(); + const { InviteUserToRoomInput } = commandModule; + return InviteUserToRoomInput; + } + + requireInputFields = ['roomId', 'userId']; + + protected async run( + input: BaseCommandModule.InviteUserToRoomInput, + ): Promise { + await this.matrixService.ready; + let userId = this.matrixService.getFullUserId(input.userId); + if (await this.matrixService.isUserInRoom(input.roomId, userId)) { + throw new Error(`user already in room: ${userId}`); + } + await this.matrixService.inviteUserToRoom(input.roomId, userId); + } +} diff --git a/packages/host/app/components/operator-mode/code-submode/left-panel-toggle.gts b/packages/host/app/components/operator-mode/code-submode/left-panel-toggle.gts index 27d3de1f151..dd32657f5f3 100644 --- a/packages/host/app/components/operator-mode/code-submode/left-panel-toggle.gts +++ b/packages/host/app/components/operator-mode/code-submode/left-panel-toggle.gts @@ -10,17 +10,15 @@ import { Button as BoxelButton } from '@cardstack/boxel-ui/components'; import { cn, not } from '@cardstack/boxel-ui/helpers'; import { Download } from '@cardstack/boxel-ui/icons'; +import { createURLSignature } from '@cardstack/runtime-common/url-signature'; + import RealmDropdown from '@cardstack/host/components/realm-dropdown'; -// These were inline but caused the template to have spurious Glint errors -import { - extractFilename, - fallbackDownloadName, -} from '@cardstack/host/lib/download-realm'; +// This was inline but caused the template to have spurious Glint errors +import { fallbackDownloadName } from '@cardstack/host/lib/download-realm'; import RestoreScrollPosition from '@cardstack/host/modifiers/restore-scroll-position'; -import type NetworkService from '@cardstack/host/services/network'; import type { FileView } from '@cardstack/host/services/operator-mode-state-service'; import type OperatorModeStateService from '@cardstack/host/services/operator-mode-state-service'; import type RealmService from '@cardstack/host/services/realm'; @@ -47,7 +45,6 @@ interface Signature { export default class CodeSubmodeLeftPanelToggle extends Component { @service declare operatorModeStateService: OperatorModeStateService; @service declare private recentFilesService: RecentFilesService; - @service declare private network: NetworkService; @service declare private realm: RealmService; private notifyFileBrowserIsVisible: (() => void) | undefined; @@ -107,43 +104,31 @@ export default class CodeSubmodeLeftPanelToggle extends Component { this.switchRealm(realmItem.path); }; - private get downloadRealmURL() { + private get downloadFilename() { + return fallbackDownloadName(new URL(this.args.realmURL)); + } + + downloadRealm = async (event: Event) => { + event.preventDefault(); + let downloadURL = new URL('/_download-realm', this.args.realmURL); downloadURL.searchParams.set('realm', this.args.realmURL); - return downloadURL.href; - } - private triggerDownload(blob: Blob, filename: string) { - let blobUrl = URL.createObjectURL(blob); + let token = this.realm.token(this.args.realmURL); + if (token) { + downloadURL.searchParams.set('token', token); + // Add signature binding the token to this specific URL + let sig = await createURLSignature(token, downloadURL); + downloadURL.searchParams.set('sig', sig); + } + + // Use an anchor element to trigger native browser download (streams without loading into memory) let downloadLink = document.createElement('a'); - downloadLink.href = blobUrl; - downloadLink.download = filename; + downloadLink.href = downloadURL.href; + downloadLink.download = this.downloadFilename; document.body.appendChild(downloadLink); downloadLink.click(); document.body.removeChild(downloadLink); - URL.revokeObjectURL(blobUrl); - } - - downloadRealm = async (event: Event) => { - event.preventDefault(); - try { - let token = this.realm.token(this.args.realmURL); - let response = await this.network.authedFetch(this.downloadRealmURL, { - headers: token ? { Authorization: token } : {}, - }); - if (!response.ok) { - throw new Error( - `Failed to download realm: ${response.status} ${response.statusText}`, - ); - } - let blob = await response.blob(); - let filename = - extractFilename(response.headers.get('content-disposition')) ?? - fallbackDownloadName(new URL(this.args.realmURL)); - this.triggerDownload(blob, filename); - } catch (error) { - console.error('Error downloading realm:', error); - } }; }; } + // A card whose query has no predicate filter, only a page size. + // normalizeQueryDefinition turns this into { type: targetRef } — an + // explicit CardTypeFilter — instead of injecting `on` into leaf predicates. + class TypeFilterCard extends CardDef { + @field matches = linksToMany(() => Person, { + query: { + page: { size: 10, number: 0 }, + }, + }); + static isolated = class Isolated extends Component< + typeof TypeFilterCard + > { + + }; + } await setupAcceptanceTestRealm({ mockMatrixUtils, contents: { - 'query-card.gts': { Person, QueryCard }, + 'query-card.gts': { Person, QueryCard, Animal }, 'query-card-nested.gts': { Person, QueryCardNested, QueryLinksField }, + 'type-filter-card.gts': { TypeFilterCard, Person }, 'Person/target.json': new Person({ name: 'Target' }), 'Person/not-target.json': new Person({ name: 'Not Target' }), + 'Animal/target.json': new Animal({ name: 'Target' }), 'query-card.json': new QueryCard({ cardTitle: 'Target', }), @@ -170,6 +198,7 @@ module( 'query-card-missing.json': new QueryCard({ cardTitle: 'Missing', }), + 'type-filter-card.json': new TypeFilterCard(), }, }); loader = getService('loader-service').loader; @@ -564,5 +593,324 @@ module( network.virtualNetwork.unmount(handler); } }); + + test('client falls back to live search when server-side cross-realm query has errors', async function (assert) { + let network = getService('network') as NetworkService; + let interceptedSearchRequests: string[] = []; + + // Pre-fetch the card JSON before mounting the handler so we can + // modify it without re-fetching from inside the handler (which + // can interfere with module loading through the virtual network). + let prefetchResponse = await network.virtualNetwork.fetch( + new Request(QUERY_CARD_URL, { + headers: { Accept: SupportedMimeType.CardJson }, + }), + ); + let cardJson = await prefetchResponse.json(); + + // Inject error metadata into the matches relationship, + // simulating a failed cross-realm query + let fakeRemoteRealm = 'https://unreachable-realm.example.com/'; + let matchesRel = cardJson.data.relationships?.matches; + if (matchesRel) { + // Clear any results that were populated — simulating server couldn't get them + matchesRel.data = []; + matchesRel.meta = { + errors: [ + { + realm: fakeRemoteRealm, + type: 'fetch-error', + message: 'Could not reach remote realm', + status: 502, + }, + ], + }; + // Remove indexed per-item relationships (matches.0, matches.1, ...) + for (let key of Object.keys(cardJson.data.relationships)) { + if (key.startsWith('matches.')) { + delete cardJson.data.relationships[key]; + } + } + } + + let modifiedBody = JSON.stringify(cardJson); + + let handler = async (request: Request) => { + let url = new URL(request.url); + + // Track client-side search requests + if (url.pathname.endsWith('/_search')) { + interceptedSearchRequests.push(request.url); + } + + // Return the pre-modified card JSON for the card GET request. + if ( + request.method === 'GET' && + request.url === QUERY_CARD_URL && + request.headers.get('Accept')?.includes('card+json') + ) { + return new Response(modifiedBody, { + status: 200, + headers: new Headers({ + 'content-type': SupportedMimeType.CardJson, + }), + }); + } + + return null; + }; + + network.virtualNetwork.mount(handler, { prepend: true }); + try { + await visitOperatorMode({ + stacks: [[{ id: QUERY_CARD_URL, format: 'isolated' }]], + }); + await settled(); + + let cardSelector = `[data-test-stack-card="${QUERY_CARD_URL}"]`; + assert.dom(cardSelector).exists('query card is rendered'); + await waitFor(`${cardSelector} [data-test-matches]`); + + assert.ok( + interceptedSearchRequests.length > 0, + 'client-side _search request was triggered as a fallback for the errored query field', + ); + + let matchElements = findAll(`${cardSelector} [data-test-match]`); + assert.deepEqual( + matchElements.map((el) => el.textContent?.trim()), + ['Target'], + 'linksToMany query field was populated via client-side fallback search', + ); + } finally { + network.virtualNetwork.unmount(handler); + } + }); + + test('fallback search preserves the type filter from the query definition', async function (assert) { + // This test verifies that when the client falls back to a live search + // due to server-side cross-realm query errors, the search correctly + // includes the type constraint. Both Person/target and Animal/target + // have name "Target", but the query field is typed as linksToMany(Person), + // so only Person cards should appear in the results. + let network = getService('network') as NetworkService; + let interceptedSearchRequests: string[] = []; + + let prefetchResponse = await network.virtualNetwork.fetch( + new Request(QUERY_CARD_URL, { + headers: { Accept: SupportedMimeType.CardJson }, + }), + ); + let cardJson = await prefetchResponse.json(); + + let fakeRemoteRealm = 'https://unreachable-realm.example.com/'; + let matchesRel = cardJson.data.relationships?.matches; + if (matchesRel) { + matchesRel.data = []; + matchesRel.meta = { + errors: [ + { + realm: fakeRemoteRealm, + type: 'fetch-error', + message: 'Could not reach remote realm', + status: 502, + }, + ], + }; + for (let key of Object.keys(cardJson.data.relationships)) { + if (key.startsWith('matches.')) { + delete cardJson.data.relationships[key]; + } + } + } + + let modifiedBody = JSON.stringify(cardJson); + + let handler = async (request: Request) => { + let url = new URL(request.url); + + if (url.pathname.endsWith('/_search')) { + interceptedSearchRequests.push(request.url); + } + + if ( + request.method === 'GET' && + request.url === QUERY_CARD_URL && + request.headers.get('Accept')?.includes('card+json') + ) { + return new Response(modifiedBody, { + status: 200, + headers: new Headers({ + 'content-type': SupportedMimeType.CardJson, + }), + }); + } + + return null; + }; + + network.virtualNetwork.mount(handler, { prepend: true }); + try { + await visitOperatorMode({ + stacks: [[{ id: QUERY_CARD_URL, format: 'isolated' }]], + }); + await settled(); + + let cardSelector = `[data-test-stack-card="${QUERY_CARD_URL}"]`; + assert.dom(cardSelector).exists('query card is rendered'); + await waitFor(`${cardSelector} [data-test-matches]`); + + assert.ok( + interceptedSearchRequests.length > 0, + 'client-side _search request was triggered as a fallback', + ); + + let matchElements = findAll(`${cardSelector} [data-test-match]`); + assert.deepEqual( + matchElements.map((el) => el.textContent?.trim()), + ['Target'], + 'only Person cards appear — Animal/target with same name is excluded by the type filter', + ); + } finally { + network.virtualNetwork.unmount(handler); + } + }); + + test('explicit CardTypeFilter hydrates from server without re-fetch', async function (assert) { + // TypeFilterCard has a linksToMany query with no filter predicates, + // so normalizeQueryDefinition produces { type: targetRef } — a pure + // CardTypeFilter. This is the happy-path: server-populated results + // are used directly with no client-side search needed. + let network = getService('network') as NetworkService; + let interceptedSearchRequests: string[] = []; + let handler = async (request: Request) => { + let url = new URL(request.url); + if (url.pathname.endsWith('/_search')) { + interceptedSearchRequests.push(request.url); + } + return null; + }; + + network.virtualNetwork.mount(handler, { prepend: true }); + try { + await visitOperatorMode({ + stacks: [[{ id: TYPE_FILTER_CARD_URL, format: 'isolated' }]], + }); + await settled(); + + let cardSelector = `[data-test-stack-card="${TYPE_FILTER_CARD_URL}"]`; + assert.dom(cardSelector).exists('type-filter card is rendered'); + await waitFor(`${cardSelector} [data-test-matches]`); + + assert.strictEqual( + interceptedSearchRequests.length, + 0, + 'no _search requests were triggered — server-populated results were used directly', + ); + + let matchElements = findAll(`${cardSelector} [data-test-match]`); + let matchNames = matchElements + .map((el) => el.textContent?.trim()) + .sort(); + assert.deepEqual( + matchNames, + ['Not Target', 'Target'], + 'all Person cards are hydrated from server response — Animal/target is excluded by CardTypeFilter', + ); + } finally { + network.virtualNetwork.unmount(handler); + } + }); + + test('fallback search works with an explicit CardTypeFilter (no predicate)', async function (assert) { + // TypeFilterCard has a linksToMany query with no filter predicates, + // so normalizeQueryDefinition produces { type: targetRef } — a pure + // CardTypeFilter. This test verifies the fallback search preserves + // that filter and returns only Person cards (not Animal cards). + let network = getService('network') as NetworkService; + let interceptedSearchRequests: string[] = []; + + let prefetchResponse = await network.virtualNetwork.fetch( + new Request(TYPE_FILTER_CARD_URL, { + headers: { Accept: SupportedMimeType.CardJson }, + }), + ); + let cardJson = await prefetchResponse.json(); + + let fakeRemoteRealm = 'https://unreachable-realm.example.com/'; + let matchesRel = cardJson.data.relationships?.matches; + if (matchesRel) { + matchesRel.data = []; + matchesRel.meta = { + errors: [ + { + realm: fakeRemoteRealm, + type: 'fetch-error', + message: 'Could not reach remote realm', + status: 502, + }, + ], + }; + for (let key of Object.keys(cardJson.data.relationships)) { + if (key.startsWith('matches.')) { + delete cardJson.data.relationships[key]; + } + } + } + + let modifiedBody = JSON.stringify(cardJson); + + let handler = async (request: Request) => { + let url = new URL(request.url); + + if (url.pathname.endsWith('/_search')) { + interceptedSearchRequests.push(request.url); + } + + if ( + request.method === 'GET' && + request.url === TYPE_FILTER_CARD_URL && + request.headers.get('Accept')?.includes('card+json') + ) { + return new Response(modifiedBody, { + status: 200, + headers: new Headers({ + 'content-type': SupportedMimeType.CardJson, + }), + }); + } + + return null; + }; + + network.virtualNetwork.mount(handler, { prepend: true }); + try { + await visitOperatorMode({ + stacks: [[{ id: TYPE_FILTER_CARD_URL, format: 'isolated' }]], + }); + await settled(); + + let cardSelector = `[data-test-stack-card="${TYPE_FILTER_CARD_URL}"]`; + assert.dom(cardSelector).exists('type-filter card is rendered'); + await waitFor(`${cardSelector} [data-test-matches]`); + + assert.ok( + interceptedSearchRequests.length > 0, + 'client-side _search request was triggered as a fallback', + ); + + let matchElements = findAll(`${cardSelector} [data-test-match]`); + let matchNames = matchElements + .map((el) => el.textContent?.trim()) + .sort(); + assert.deepEqual( + matchNames, + ['Not Target', 'Target'], + 'all Person cards are returned by the type filter — Animal/target is excluded', + ); + } finally { + network.virtualNetwork.unmount(handler); + } + }); }, ); diff --git a/packages/host/tests/acceptance/svg-image-def-test.gts b/packages/host/tests/acceptance/svg-image-def-test.gts new file mode 100644 index 00000000000..86173dc3b4c --- /dev/null +++ b/packages/host/tests/acceptance/svg-image-def-test.gts @@ -0,0 +1,336 @@ +import { visit, waitUntil } from '@ember/test-helpers'; + +import { getService } from '@universal-ember/test-support'; + +import { module, test } from 'qunit'; + +import { + baseRealm, + type FileExtractResponse, + type RenderRouteOptions, + type ResolvedCodeRef, + SupportedMimeType, +} from '@cardstack/runtime-common'; +import type { Realm } from '@cardstack/runtime-common/realm'; + +import type NetworkService from '@cardstack/host/services/network'; + +import { + setupLocalIndexing, + setupOnSave, + testRealmURL, + setupAcceptanceTestRealm, + SYSTEM_CARD_FIXTURE_CONTENTS, + capturePrerenderResult, +} from '../helpers'; +import { setupMockMatrix } from '../helpers/mock-matrix'; +import { setupApplicationTest } from '../helpers/setup'; + +function makeMinimalSvg(width: number, height: number): string { + return ``; +} + +module('Acceptance | svg image def', function (hooks) { + setupApplicationTest(hooks); + setupLocalIndexing(hooks); + setupOnSave(hooks); + + let mockMatrixUtils = setupMockMatrix(hooks, { + loggedInAs: '@testuser:localhost', + }); + let realm: Realm; + + const fileExtractPath = ( + url: string, + renderOptions: RenderRouteOptions, + nonce = 0, + ) => + `/render/${encodeURIComponent(url)}/${nonce}/${encodeURIComponent( + JSON.stringify(renderOptions), + )}/file-extract`; + + const fileRenderPath = ( + url: string, + renderOptions: RenderRouteOptions, + format = 'isolated', + ancestorLevel = 0, + nonce = 0, + ) => + `/render/${encodeURIComponent(url)}/${nonce}/${encodeURIComponent( + JSON.stringify(renderOptions), + )}/html/${format}/${ancestorLevel}`; + + const makeFileURL = (path: string) => new URL(path, testRealmURL).href; + + const svgDefCodeRef = (): ResolvedCodeRef => ({ + module: `${baseRealm.url}svg-image-def`, + name: 'SvgDef', + }); + + async function captureFileExtractResult( + expectedStatus?: 'ready' | 'error', + ): Promise { + await waitUntil( + () => { + let container = document.querySelector( + '[data-prerender-file-extract]', + ) as HTMLElement | null; + if (!container) { + return false; + } + let status = container.getAttribute( + 'data-prerender-file-extract-status', + ); + if (!status) { + return false; + } + if (expectedStatus && status !== expectedStatus) { + return false; + } + return status === 'ready' || status === 'error'; + }, + { timeout: 5000 }, + ); + + let container = document.querySelector( + '[data-prerender-file-extract]', + ) as HTMLElement | null; + if (!container) { + throw new Error( + 'captureFileExtractResult: missing [data-prerender-file-extract] container after wait', + ); + } + let pre = container.querySelector('pre'); + let text = pre?.textContent?.trim() ?? ''; + return JSON.parse(text) as FileExtractResponse; + } + + hooks.beforeEach(async function () { + ({ realm } = await setupAcceptanceTestRealm({ + mockMatrixUtils, + contents: { + ...SYSTEM_CARD_FIXTURE_CONTENTS, + 'sample.svg': makeMinimalSvg(120, 80), + 'viewbox-only.svg': + '', + 'not-an-svg.svg': 'This is plain text, not an SVG file.', + }, + })); + }); + + hooks.afterEach(function () { + delete (globalThis as any).__renderModel; + delete (globalThis as any).__boxelFileRenderData; + }); + + test('extracts width and height from SVG with explicit attributes', async function (assert) { + let url = makeFileURL('sample.svg'); + await visit( + fileExtractPath(url, { + fileExtract: true, + fileDefCodeRef: svgDefCodeRef(), + }), + ); + + let result = await captureFileExtractResult('ready'); + assert.strictEqual(result.status, 'ready'); + assert.strictEqual(result.searchDoc?.width, 120, 'extracts SVG width'); + assert.strictEqual(result.searchDoc?.height, 80, 'extracts SVG height'); + assert.strictEqual(result.searchDoc?.name, 'sample.svg'); + assert.ok( + String(result.searchDoc?.contentType).includes('svg'), + 'sets svg content type', + ); + }); + + test('extracts dimensions from viewBox when width/height attributes are absent', async function (assert) { + let url = makeFileURL('viewbox-only.svg'); + await visit( + fileExtractPath(url, { + fileExtract: true, + fileDefCodeRef: svgDefCodeRef(), + }), + ); + + let result = await captureFileExtractResult('ready'); + assert.strictEqual(result.status, 'ready'); + assert.strictEqual( + result.searchDoc?.width, + 200, + 'extracts width from viewBox', + ); + assert.strictEqual( + result.searchDoc?.height, + 150, + 'extracts height from viewBox', + ); + }); + + test('falls back when SvgDef is used for non-SVG content', async function (assert) { + let url = makeFileURL('not-an-svg.svg'); + await visit( + fileExtractPath(url, { + fileExtract: true, + fileDefCodeRef: svgDefCodeRef(), + }), + ); + + let result = await captureFileExtractResult('ready'); + assert.strictEqual(result.status, 'ready'); + assert.true( + result.mismatch, + 'marks mismatch when content is not valid SVG', + ); + assert.strictEqual(result.searchDoc?.name, 'not-an-svg.svg'); + }); + + test('isolated template renders img with width and height attributes', async function (assert) { + let url = makeFileURL('sample.svg'); + + // First extract the file to get the resource + await visit( + fileExtractPath(url, { + fileExtract: true, + fileDefCodeRef: svgDefCodeRef(), + }), + ); + let result = await captureFileExtractResult('ready'); + assert.ok(result.resource, 'extraction produced a resource'); + + // Set up file render data and visit the HTML render route + (globalThis as any).__boxelFileRenderData = { + resource: result.resource, + fileDefCodeRef: svgDefCodeRef(), + }; + + await visit( + fileRenderPath(url, { + fileRender: true, + fileDefCodeRef: svgDefCodeRef(), + }), + ); + + let { status } = await capturePrerenderResult('innerHTML'); + assert.strictEqual(status, 'ready', 'render completed'); + + let img = document.querySelector( + '[data-prerender] img', + ) as HTMLImageElement | null; + assert.ok(img, 'img element is rendered'); + assert.strictEqual( + img?.getAttribute('width'), + '120', + 'img has correct width attribute', + ); + assert.strictEqual( + img?.getAttribute('height'), + '80', + 'img has correct height attribute', + ); + assert.ok( + img?.getAttribute('src')?.includes('sample.svg'), + 'img src references the SVG file', + ); + }); + + test('indexing stores SVG metadata and file meta uses it', async function (assert) { + let fileURL = new URL('sample.svg', testRealmURL); + let fileEntry = await realm.realmIndexQueryEngine.file(fileURL); + + assert.ok(fileEntry, 'file entry exists'); + assert.strictEqual( + fileEntry?.searchDoc?.width, + 120, + 'index stores SVG width', + ); + assert.strictEqual( + fileEntry?.searchDoc?.height, + 80, + 'index stores SVG height', + ); + + let network = getService('network') as NetworkService; + let response = await network.virtualNetwork.fetch(fileURL, { + headers: { Accept: SupportedMimeType.FileMeta }, + }); + + assert.true(response.ok, 'file meta request succeeds'); + + let body = await response.json(); + assert.strictEqual(body?.data?.type, 'file-meta'); + assert.ok( + String(body?.data?.attributes?.contentType).includes('svg'), + 'file meta uses svg content type', + ); + assert.strictEqual( + body?.data?.attributes?.width, + 120, + 'file meta includes SVG width', + ); + assert.strictEqual( + body?.data?.attributes?.height, + 80, + 'file meta includes SVG height', + ); + assert.deepEqual( + body?.data?.meta?.adoptsFrom, + svgDefCodeRef(), + 'file meta uses SVG def', + ); + }); + + test('authenticated images display in browser', async function (assert) { + let url = makeFileURL('sample.svg'); + + // First extract the file to get the resource + await visit( + fileExtractPath(url, { + fileExtract: true, + fileDefCodeRef: svgDefCodeRef(), + }), + ); + let result = await captureFileExtractResult('ready'); + assert.ok(result.resource, 'extraction produced a resource'); + + // Set up file render data and visit the HTML render route + (globalThis as any).__boxelFileRenderData = { + resource: result.resource, + fileDefCodeRef: svgDefCodeRef(), + }; + + await visit( + fileRenderPath(url, { + fileRender: true, + fileDefCodeRef: svgDefCodeRef(), + }), + ); + + let { status } = await capturePrerenderResult('innerHTML'); + assert.strictEqual(status, 'ready', 'render completed'); + + let img = document.querySelector( + '[data-prerender] .image-isolated__img', + ) as HTMLImageElement | null; + assert.ok(img, 'img element is rendered'); + assert.ok( + img?.getAttribute('src')?.includes('sample.svg'), + 'img src references the SVG file', + ); + + // Wait for the image to actually load and verify it has non-zero dimensions. + await waitUntil(() => img!.naturalWidth > 0, { + timeout: 5000, + timeoutMessage: + 'Image failed to load - naturalWidth remained 0. This likely indicates an authentication issue preventing the browser from fetching the image.', + }); + + assert.ok( + img!.naturalWidth > 0, + 'Image loaded successfully with non-zero width', + ); + assert.ok( + img!.naturalHeight > 0, + 'Image loaded successfully with non-zero height', + ); + }); +}); diff --git a/packages/host/tests/acceptance/webp-image-def-test.gts b/packages/host/tests/acceptance/webp-image-def-test.gts new file mode 100644 index 00000000000..31cdd99827e --- /dev/null +++ b/packages/host/tests/acceptance/webp-image-def-test.gts @@ -0,0 +1,375 @@ +import { visit, waitUntil } from '@ember/test-helpers'; + +import { getService } from '@universal-ember/test-support'; + +import { module, test } from 'qunit'; + +import { + baseRealm, + type FileExtractResponse, + type RenderRouteOptions, + type ResolvedCodeRef, + SupportedMimeType, +} from '@cardstack/runtime-common'; +import type { Realm } from '@cardstack/runtime-common/realm'; + +import type NetworkService from '@cardstack/host/services/network'; + +import { + setupLocalIndexing, + setupOnSave, + testRealmURL, + setupAcceptanceTestRealm, + SYSTEM_CARD_FIXTURE_CONTENTS, + capturePrerenderResult, +} from '../helpers'; +import { setupMockMatrix } from '../helpers/mock-matrix'; +import { setupApplicationTest } from '../helpers/setup'; + +// Build a minimal valid WebP (lossy VP8) with specified dimensions. +// Structure: RIFF header (12) + VP8 chunk header (8) + VP8 frame header (10) +function makeMinimalWebp(width: number, height: number): Uint8Array { + let parts: number[] = []; + + // VP8 bitstream payload (10 bytes minimum) + let vp8Payload = [ + // Frame tag (3 bytes): keyframe, version 0, show_frame=1 + 0x9d, + 0x01, + 0x2a, + // Note: first 3 bytes are actually part of the frame tag; + // the start code 9D 01 2A comes next in the bitstream. + // For a minimal VP8: bytes 0-2 are frame tag, bytes 3-5 are start code + ]; + + // Corrected VP8 bitstream: + // Bytes 0-2: frame tag + // bit 0: keyframe (0 = keyframe) + // bits 1-3: version + // bit 4: show_frame + // bits 5-18: first_part_size (at least enough for header) + // We'll use: 0x00 0x00 0x00 → not quite right. Let me use a known working pattern. + vp8Payload = [ + // Frame tag: keyframe=1, version=0, show=1, partition_size + 0x30, 0x01, 0x00, + // Start code: 0x9D 0x01 0x2A + 0x9d, 0x01, 0x2a, + // Width (LE uint16, lower 14 bits = width, upper 2 bits = horizontal scale) + width & 0xff, + (width >> 8) & 0x3f, + // Height (LE uint16, lower 14 bits = height, upper 2 bits = vertical scale) + height & 0xff, + (height >> 8) & 0x3f, + ]; + + // VP8 chunk size + let vp8ChunkSize = vp8Payload.length; + + // Total RIFF file size = 4 ("WEBP") + 8 (VP8 chunk header) + VP8 chunk data + let riffSize = 4 + 8 + vp8ChunkSize; + + // RIFF header + parts.push(0x52, 0x49, 0x46, 0x46); // "RIFF" + parts.push( + riffSize & 0xff, + (riffSize >> 8) & 0xff, + (riffSize >> 16) & 0xff, + (riffSize >> 24) & 0xff, + ); + parts.push(0x57, 0x45, 0x42, 0x50); // "WEBP" + + // VP8 chunk header + parts.push(0x56, 0x50, 0x38, 0x20); // "VP8 " + parts.push( + vp8ChunkSize & 0xff, + (vp8ChunkSize >> 8) & 0xff, + (vp8ChunkSize >> 16) & 0xff, + (vp8ChunkSize >> 24) & 0xff, + ); + + // VP8 payload + parts.push(...vp8Payload); + + return new Uint8Array(parts); +} + +module('Acceptance | webp image def', function (hooks) { + setupApplicationTest(hooks); + setupLocalIndexing(hooks); + setupOnSave(hooks); + + let mockMatrixUtils = setupMockMatrix(hooks, { + loggedInAs: '@testuser:localhost', + }); + let realm: Realm; + + const fileExtractPath = ( + url: string, + renderOptions: RenderRouteOptions, + nonce = 0, + ) => + `/render/${encodeURIComponent(url)}/${nonce}/${encodeURIComponent( + JSON.stringify(renderOptions), + )}/file-extract`; + + const fileRenderPath = ( + url: string, + renderOptions: RenderRouteOptions, + format = 'isolated', + ancestorLevel = 0, + nonce = 0, + ) => + `/render/${encodeURIComponent(url)}/${nonce}/${encodeURIComponent( + JSON.stringify(renderOptions), + )}/html/${format}/${ancestorLevel}`; + + const makeFileURL = (path: string) => new URL(path, testRealmURL).href; + + const webpDefCodeRef = (): ResolvedCodeRef => ({ + module: `${baseRealm.url}webp-image-def`, + name: 'WebpDef', + }); + + async function captureFileExtractResult( + expectedStatus?: 'ready' | 'error', + ): Promise { + await waitUntil( + () => { + let container = document.querySelector( + '[data-prerender-file-extract]', + ) as HTMLElement | null; + if (!container) { + return false; + } + let status = container.getAttribute( + 'data-prerender-file-extract-status', + ); + if (!status) { + return false; + } + if (expectedStatus && status !== expectedStatus) { + return false; + } + return status === 'ready' || status === 'error'; + }, + { timeout: 5000 }, + ); + + let container = document.querySelector( + '[data-prerender-file-extract]', + ) as HTMLElement | null; + if (!container) { + throw new Error( + 'captureFileExtractResult: missing [data-prerender-file-extract] container after wait', + ); + } + let pre = container.querySelector('pre'); + let text = pre?.textContent?.trim() ?? ''; + return JSON.parse(text) as FileExtractResponse; + } + + hooks.beforeEach(async function () { + let webpBytes = makeMinimalWebp(8, 9); + ({ realm } = await setupAcceptanceTestRealm({ + mockMatrixUtils, + contents: { + ...SYSTEM_CARD_FIXTURE_CONTENTS, + 'sample.webp': webpBytes, + 'not-a-webp.webp': 'This is plain text, not a WebP file.', + }, + })); + }); + + hooks.afterEach(function () { + delete (globalThis as any).__renderModel; + delete (globalThis as any).__boxelFileRenderData; + }); + + test('extracts width and height from WebP', async function (assert) { + let url = makeFileURL('sample.webp'); + await visit( + fileExtractPath(url, { + fileExtract: true, + fileDefCodeRef: webpDefCodeRef(), + }), + ); + + let result = await captureFileExtractResult('ready'); + assert.strictEqual(result.status, 'ready'); + assert.strictEqual(result.searchDoc?.width, 8, 'extracts WebP width'); + assert.strictEqual(result.searchDoc?.height, 9, 'extracts WebP height'); + assert.strictEqual(result.searchDoc?.name, 'sample.webp'); + assert.ok( + String(result.searchDoc?.contentType).includes('webp'), + 'sets webp content type', + ); + }); + + test('falls back when WebpDef is used for non-WebP content', async function (assert) { + let url = makeFileURL('not-a-webp.webp'); + await visit( + fileExtractPath(url, { + fileExtract: true, + fileDefCodeRef: webpDefCodeRef(), + }), + ); + + let result = await captureFileExtractResult('ready'); + assert.strictEqual(result.status, 'ready'); + assert.true( + result.mismatch, + 'marks mismatch when content is not valid WebP', + ); + assert.strictEqual(result.searchDoc?.name, 'not-a-webp.webp'); + }); + + test('isolated template renders img with width and height attributes', async function (assert) { + let url = makeFileURL('sample.webp'); + + // First extract the file to get the resource + await visit( + fileExtractPath(url, { + fileExtract: true, + fileDefCodeRef: webpDefCodeRef(), + }), + ); + let result = await captureFileExtractResult('ready'); + assert.ok(result.resource, 'extraction produced a resource'); + + // Set up file render data and visit the HTML render route + (globalThis as any).__boxelFileRenderData = { + resource: result.resource, + fileDefCodeRef: webpDefCodeRef(), + }; + + await visit( + fileRenderPath(url, { + fileRender: true, + fileDefCodeRef: webpDefCodeRef(), + }), + ); + + let { status } = await capturePrerenderResult('innerHTML'); + assert.strictEqual(status, 'ready', 'render completed'); + + let img = document.querySelector( + '[data-prerender] img', + ) as HTMLImageElement | null; + assert.ok(img, 'img element is rendered'); + assert.strictEqual( + img?.getAttribute('width'), + '8', + 'img has correct width attribute', + ); + assert.strictEqual( + img?.getAttribute('height'), + '9', + 'img has correct height attribute', + ); + assert.ok( + img?.getAttribute('src')?.includes('sample.webp'), + 'img src references the WebP file', + ); + }); + + test('indexing stores WebP metadata and file meta uses it', async function (assert) { + let fileURL = new URL('sample.webp', testRealmURL); + let fileEntry = await realm.realmIndexQueryEngine.file(fileURL); + + assert.ok(fileEntry, 'file entry exists'); + assert.strictEqual( + fileEntry?.searchDoc?.width, + 8, + 'index stores WebP width', + ); + assert.strictEqual( + fileEntry?.searchDoc?.height, + 9, + 'index stores WebP height', + ); + + let network = getService('network') as NetworkService; + let response = await network.virtualNetwork.fetch(fileURL, { + headers: { Accept: SupportedMimeType.FileMeta }, + }); + + assert.true(response.ok, 'file meta request succeeds'); + + let body = await response.json(); + assert.strictEqual(body?.data?.type, 'file-meta'); + assert.ok( + String(body?.data?.attributes?.contentType).includes('webp'), + 'file meta uses webp content type', + ); + assert.strictEqual( + body?.data?.attributes?.width, + 8, + 'file meta includes WebP width', + ); + assert.strictEqual( + body?.data?.attributes?.height, + 9, + 'file meta includes WebP height', + ); + assert.deepEqual( + body?.data?.meta?.adoptsFrom, + webpDefCodeRef(), + 'file meta uses WebP def', + ); + }); + + test('authenticated images display in browser', async function (assert) { + let url = makeFileURL('sample.webp'); + + // First extract the file to get the resource + await visit( + fileExtractPath(url, { + fileExtract: true, + fileDefCodeRef: webpDefCodeRef(), + }), + ); + let result = await captureFileExtractResult('ready'); + assert.ok(result.resource, 'extraction produced a resource'); + + // Set up file render data and visit the HTML render route + (globalThis as any).__boxelFileRenderData = { + resource: result.resource, + fileDefCodeRef: webpDefCodeRef(), + }; + + await visit( + fileRenderPath(url, { + fileRender: true, + fileDefCodeRef: webpDefCodeRef(), + }), + ); + + let { status } = await capturePrerenderResult('innerHTML'); + assert.strictEqual(status, 'ready', 'render completed'); + + let img = document.querySelector( + '[data-prerender] .image-isolated__img', + ) as HTMLImageElement | null; + assert.ok(img, 'img element is rendered'); + assert.ok( + img?.getAttribute('src')?.includes('sample.webp'), + 'img src references the WebP file', + ); + + // Wait for the image to actually load and verify it has non-zero dimensions. + await waitUntil(() => img!.naturalWidth > 0, { + timeout: 5000, + timeoutMessage: + 'Image failed to load - naturalWidth remained 0. This likely indicates an authentication issue preventing the browser from fetching the image.', + }); + + assert.ok( + img!.naturalWidth > 0, + 'Image loaded successfully with non-zero width', + ); + assert.ok( + img!.naturalHeight > 0, + 'Image loaded successfully with non-zero height', + ); + }); +}); diff --git a/packages/host/tests/helpers/adapter.ts b/packages/host/tests/helpers/adapter.ts index 8d3fa14b578..303738359d2 100644 --- a/packages/host/tests/helpers/adapter.ts +++ b/packages/host/tests/helpers/adapter.ts @@ -57,7 +57,7 @@ class TokenExpiredError extends Error {} class JsonWebTokenError extends Error {} interface TestAdapterContents { - [path: string]: string | object; + [path: string]: string | object | Uint8Array; } let shimmedModuleIndicator = '// this file is shimmed'; @@ -255,7 +255,9 @@ export class TestRealmAdapter implements RealmAdapter { let fileRefContent: string | Uint8Array = ''; - if (path.endsWith('.json')) { + if (value instanceof Uint8Array) { + fileRefContent = value; + } else if (path.endsWith('.json')) { let cardApi = await this.#loader.import( `${baseRealm.url}card-api`, ); @@ -272,8 +274,6 @@ export class TestRealmAdapter implements RealmAdapter { } else { fileRefContent = shimmedModuleIndicator; } - } else if (value instanceof Uint8Array) { - fileRefContent = value; } else { fileRefContent = value as string; } diff --git a/packages/host/tests/helpers/index.gts b/packages/host/tests/helpers/index.gts index a5a56aec045..7555bfc21bd 100644 --- a/packages/host/tests/helpers/index.gts +++ b/packages/host/tests/helpers/index.gts @@ -500,7 +500,8 @@ interface RealmContents { | LooseSingleCardDocument | RealmInfo | Record - | string; + | string + | Uint8Array; } export const SYSTEM_CARD_FIXTURE_CONTENTS: RealmContents = { @@ -997,6 +998,62 @@ export function delay(delayAmountMs: number): Promise { }); } +// Create minimal valid PNG bytes for testing (1x1 pixel by default) +export function makeMinimalPng(width = 1, height = 1): Uint8Array { + let signature = [137, 80, 78, 71, 13, 10, 26, 10]; + let ihdrData = new Uint8Array(13); + let ihdrView = new DataView(ihdrData.buffer); + ihdrView.setUint32(0, width); + ihdrView.setUint32(4, height); + ihdrData[8] = 8; // bit depth + ihdrData[9] = 2; // color type (RGB) + ihdrData[10] = 0; // compression + ihdrData[11] = 0; // filter + ihdrData[12] = 0; // interlace + let ihdrChunk = buildPngChunk('IHDR', ihdrData); + let idatData = new Uint8Array([ + 0x08, 0xd7, 0x01, 0x00, 0x00, 0xff, 0xff, 0x00, 0x01, 0x00, 0x01, + ]); + let idatChunk = buildPngChunk('IDAT', idatData); + let iendChunk = buildPngChunk('IEND', new Uint8Array(0)); + let totalLength = + signature.length + ihdrChunk.length + idatChunk.length + iendChunk.length; + let png = new Uint8Array(totalLength); + let offset = 0; + png.set(signature, offset); + offset += signature.length; + png.set(ihdrChunk, offset); + offset += ihdrChunk.length; + png.set(idatChunk, offset); + offset += idatChunk.length; + png.set(iendChunk, offset); + return png; +} + +function buildPngChunk(type: string, data: Uint8Array): Uint8Array { + let chunk = new Uint8Array(4 + 4 + data.length + 4); + let view = new DataView(chunk.buffer); + view.setUint32(0, data.length); + for (let i = 0; i < 4; i++) { + chunk[4 + i] = type.charCodeAt(i); + } + chunk.set(data, 8); + let crc = crc32Png(chunk.slice(4, 8 + data.length)); + view.setUint32(8 + data.length, crc); + return chunk; +} + +function crc32Png(data: Uint8Array): number { + let crc = 0xffffffff; + for (let i = 0; i < data.length; i++) { + crc ^= data[i]!; + for (let j = 0; j < 8; j++) { + crc = crc & 1 ? (crc >>> 1) ^ 0xedb88320 : crc >>> 1; + } + } + return (crc ^ 0xffffffff) >>> 0; +} + // --- Created-at test utilities --- // Returns created_at (epoch seconds) from realm_file_meta for a given local file path like 'Pet/mango.json'. export async function getFileCreatedAt( diff --git a/packages/host/tests/helpers/mock-matrix/_client.ts b/packages/host/tests/helpers/mock-matrix/_client.ts index 96d5f95c7a9..954ee987803 100644 --- a/packages/host/tests/helpers/mock-matrix/_client.ts +++ b/packages/host/tests/helpers/mock-matrix/_client.ts @@ -345,11 +345,27 @@ export class MockClient implements ExtendedClient { } invite( - _roomId: string, - _userId: string, + roomId: string, + userId: string, _reason?: string | undefined, ): Promise<{}> { - throw new Error('Method not implemented.'); + let sender = + this.loggedInAs ?? this.clientOpts.userId ?? '@test_user:localhost'; + let timestamp = Date.now(); + this.serverState.setRoomState( + sender, + roomId, + 'm.room.member', + { + displayname: userId, + membership: 'invite', + membershipTs: timestamp, + membershipInitiator: sender, + }, + userId, + timestamp, + ); + return Promise.resolve({}); } joinRoom( diff --git a/packages/host/tests/integration/commands/invite-user-to-room-test.gts b/packages/host/tests/integration/commands/invite-user-to-room-test.gts new file mode 100644 index 00000000000..84a830a2e20 --- /dev/null +++ b/packages/host/tests/integration/commands/invite-user-to-room-test.gts @@ -0,0 +1,102 @@ +import { getOwner } from '@ember/owner'; +import type { RenderingTestContext } from '@ember/test-helpers'; + +import { getService } from '@universal-ember/test-support'; +import { module, test } from 'qunit'; + +import InviteUserToRoomCommand from '@cardstack/host/commands/invite-user-to-room'; + +import type MatrixService from '@cardstack/host/services/matrix-service'; +import RealmService from '@cardstack/host/services/realm'; + +import { + setupIntegrationTestRealm, + setupLocalIndexing, + testRealmURL, + testRealmInfo, +} from '../../helpers'; + +import { setupMockMatrix } from '../../helpers/mock-matrix'; +import { setupRenderingTest } from '../../helpers/setup'; + +class StubRealmService extends RealmService { + get defaultReadableRealm() { + return { + path: testRealmURL, + info: testRealmInfo, + }; + } +} + +module('Integration | commands | invite-user-to-room', function (hooks) { + setupRenderingTest(hooks); + setupLocalIndexing(hooks); + + let mockMatrixUtils = setupMockMatrix(hooks, { + loggedInAs: '@testuser:localhost', + activeRealms: [testRealmURL], + }); + + let { createAndJoinRoom, getRoomState } = mockMatrixUtils; + + hooks.beforeEach(function (this: RenderingTestContext) { + getOwner(this)!.register('service:realm', StubRealmService); + }); + + hooks.beforeEach(async function () { + await setupIntegrationTestRealm({ + mockMatrixUtils, + contents: {}, + }); + }); + + test('invites a user to a room', async function (assert) { + let roomId = createAndJoinRoom({ + sender: '@testuser:localhost', + name: 'room-test', + }); + let commandService = getService('command-service'); + let matrixService = getService('matrix-service') as MatrixService; + + let command = new InviteUserToRoomCommand(commandService.commandContext); + await command.execute({ + roomId, + userId: 'bot-runner', + }); + + let botRunnerUserId = matrixService.getFullUserId('bot-runner'); + let membershipEvent = getRoomState( + roomId, + 'm.room.member', + botRunnerUserId, + ); + assert.strictEqual( + membershipEvent.membership, + 'invite', + 'bot-runner invited to room', + ); + }); + + test('rejects inviting a user twice', async function (assert) { + let roomId = createAndJoinRoom({ + sender: '@testuser:localhost', + name: 'room-test', + }); + let commandService = getService('command-service'); + + let command = new InviteUserToRoomCommand(commandService.commandContext); + await command.execute({ + roomId, + userId: 'bot-runner', + }); + + await assert.rejects( + command.execute({ + roomId, + userId: 'bot-runner', + }), + /user already in room/, + 'rejects inviting a user that is already in the room', + ); + }); +}); diff --git a/packages/host/tests/integration/components/card-basics-test.gts b/packages/host/tests/integration/components/card-basics-test.gts index a564fe84e8b..a5928c364b6 100644 --- a/packages/host/tests/integration/components/card-basics-test.gts +++ b/packages/host/tests/integration/components/card-basics-test.gts @@ -715,7 +715,7 @@ module('Integration | card-basics', function (hooks) { mockMatrixUtils, contents: { 'test-cards.gts': { Gallery }, - 'hero.png': 'mock image bytes', + 'hero.txt': 'some file content', 'Gallery/hero.json': { data: { type: 'card', @@ -725,10 +725,10 @@ module('Integration | card-basics', function (hooks) { relationships: { hero: { links: { - self: `${testRealmURL}hero.png`, + self: `${testRealmURL}hero.txt`, }, data: { - id: `${testRealmURL}hero.png`, + id: `${testRealmURL}hero.txt`, type: 'file-meta', }, }, @@ -752,13 +752,13 @@ module('Integration | card-basics', function (hooks) { await waitUntil(() => document .querySelector('[data-test-gallery-fitted]') - ?.textContent?.includes('hero.png'), + ?.textContent?.includes('hero.txt'), ); assert .dom('[data-test-gallery-fitted]') .includesText( - 'hero.png', + 'hero.txt', 'FileDef renders delegated view from file meta', ); }); @@ -781,8 +781,8 @@ module('Integration | card-basics', function (hooks) { mockMatrixUtils, contents: { 'test-cards.gts': { Gallery }, - 'first.png': 'first mock image', - 'second.png': 'second mock image', + 'first.txt': 'first file content', + 'second.txt': 'second file content', 'Gallery/attachments.json': { data: { type: 'card', @@ -792,19 +792,19 @@ module('Integration | card-basics', function (hooks) { relationships: { 'attachments.0': { links: { - self: `${testRealmURL}first.png`, + self: `${testRealmURL}first.txt`, }, data: { - id: `${testRealmURL}first.png`, + id: `${testRealmURL}first.txt`, type: 'file-meta', }, }, 'attachments.1': { links: { - self: `${testRealmURL}second.png`, + self: `${testRealmURL}second.txt`, }, data: { - id: `${testRealmURL}second.png`, + id: `${testRealmURL}second.txt`, type: 'file-meta', }, }, @@ -830,7 +830,7 @@ module('Integration | card-basics', function (hooks) { document.querySelector( '[data-test-plural-view-field="attachments"]', )?.textContent ?? ''; - return text.includes('first.png') && text.includes('second.png'); + return text.includes('first.txt') && text.includes('second.txt'); }); assert @@ -839,13 +839,13 @@ module('Integration | card-basics', function (hooks) { assert .dom('[data-test-plural-view-field="attachments"]') .includesText( - 'first.png', + 'first.txt', 'FileDef renders delegated view from file meta', ); assert .dom('[data-test-plural-view-field="attachments"]') .includesText( - 'second.png', + 'second.txt', 'FileDef renders delegated view from file meta', ); }); diff --git a/packages/matrix/tests/download-realm.spec.ts b/packages/matrix/tests/download-realm.spec.ts new file mode 100644 index 00000000000..0ceefe3c2bd --- /dev/null +++ b/packages/matrix/tests/download-realm.spec.ts @@ -0,0 +1,74 @@ +import { test, expect } from './fixtures'; +import { appURL } from '../helpers/isolated-realm-server'; +import { + createSubscribedUser, + login, + setupPermissions, +} from '../helpers'; +import type { Credentials } from '../docker/synapse'; + +test.describe('Download Realm', () => { + let credentials: Credentials; + let username: string; + let password: string; + + test.beforeEach(async () => { + ({ username, password, credentials } = + await createSubscribedUser('download-realm')); + await setupPermissions(credentials.userId, `${appURL}/`); + }); + + test('can download a realm as a streaming zip file in code submode', async ({ + page, + }) => { + const operatorModeState = { + stacks: [], + codePath: `${appURL}/index.json`, + submode: 'code', + fileView: 'browser', + openDirs: {}, + }; + const stateParam = encodeURIComponent(JSON.stringify(operatorModeState)); + await login(page, username, password, { + url: `${appURL}?operatorModeState=${stateParam}`, + }); + + // Wait for the code submode to load + await expect(page.locator('[data-test-file-browser-toggle]')).toBeVisible(); + + // Wait for the download button to appear + await expect( + page.locator('[data-test-download-realm-button]'), + ).toBeVisible(); + + // Start waiting for download before clicking + const downloadPromise = page.waitForEvent('download'); + await page.locator('[data-test-download-realm-button]').click(); + + // Wait for the download to complete + const download = await downloadPromise; + + // Verify the download filename ends with .zip + expect(download.suggestedFilename()).toMatch(/\.zip$/); + + // Read the downloaded content to verify it's a valid zip file + const stream = await download.createReadStream(); + const chunks: Buffer[] = []; + for await (const chunk of stream) { + chunks.push(chunk); + // Only need first few bytes to verify zip signature + if (chunks.reduce((acc, c) => acc + c.length, 0) >= 4) { + break; + } + } + const buffer = Buffer.concat(chunks); + + // ZIP files start with 'PK' (0x50 0x4B) + expect(buffer[0]).toBe(0x50); + expect(buffer[1]).toBe(0x4b); + + // Verify the download completed (streaming worked) + const path = await download.path(); + expect(path).toBeTruthy(); + }); +}); diff --git a/packages/realm-server/handlers/handle-download-realm.ts b/packages/realm-server/handlers/handle-download-realm.ts index b2002edac78..d161bce9bb7 100644 --- a/packages/realm-server/handlers/handle-download-realm.ts +++ b/packages/realm-server/handlers/handle-download-realm.ts @@ -11,6 +11,7 @@ import { } from '@cardstack/runtime-common'; import { AuthenticationError } from '@cardstack/runtime-common/router'; import { parseRealmsParam } from '@cardstack/runtime-common/search-utils'; +import { verifyURLSignature } from '@cardstack/runtime-common/url-signature'; import archiver from 'archiver'; import { existsSync, statSync } from 'fs-extra'; import { join, resolve, sep } from 'path'; @@ -81,7 +82,29 @@ export default function handleDownloadRealm({ } let publishedRealmURLs = await getPublishedRealmURLs(dbAdapter, [realmURL]); - let authorization = ctxt.req.headers['authorization']; + // Support token via query param for streaming downloads (browser navigates directly) + let tokenFromQuery = url.searchParams.get('token'); + let authorization = ctxt.req.headers['authorization'] ?? tokenFromQuery; + + // When token is provided via query param, require a signature to prevent token reuse + if (tokenFromQuery) { + let signature = url.searchParams.get('sig'); + if (!signature) { + await sendResponseForBadRequest( + ctxt, + 'Signature required when token is provided via query parameter', + ); + return; + } + if (!verifyURLSignature(tokenFromQuery, url, signature)) { + await sendResponseForUnauthorizedRequest( + ctxt, + 'Invalid signature for download URL', + ); + return; + } + } + let readableRealms: Set; if (!authorization) { let publicPermissions = await fetchUserPermissions(dbAdapter, { diff --git a/packages/realm-server/tests/billing-test.ts b/packages/realm-server/tests/billing-test.ts index 4aa476b92de..0951b48efa6 100644 --- a/packages/realm-server/tests/billing-test.ts +++ b/packages/realm-server/tests/billing-test.ts @@ -147,7 +147,13 @@ module(basename(__filename), function () { data: [ { amount: 0, + type: 'subscription', + proration: false, price: { product: 'prod_free' }, + period: { + start: 1635873600, + end: 1638465600, + }, }, ], }, @@ -310,6 +316,8 @@ module(basename(__filename), function () { data: [ { amount: creatorPlan.monthlyPrice * 100, + type: 'subscription', + proration: false, price: { product: 'prod_creator' }, period: { start: 1, end: 2 }, }, @@ -405,12 +413,16 @@ module(basename(__filename), function () { { amount: -amountCreditedForUnusedTimeOnPreviousPlan, description: 'Unused time on Creator plan', + type: 'subscription', + proration: false, price: { product: 'prod_creator' }, period: { start: 3, end: 4 }, }, { amount: amountCreditedForRemainingTimeOnNewPlan, description: 'Remaining time on Power User plan', + type: 'subscription', + proration: false, price: { product: 'prod_power_user' }, period: { start: 4, end: 5 }, }, @@ -500,6 +512,8 @@ module(basename(__filename), function () { data: [ { amount: creatorPlan.monthlyPrice * 100, + type: 'subscription', + proration: false, price: { product: 'prod_creator' }, period: { start: 5, end: 6 }, }, @@ -578,7 +592,13 @@ module(basename(__filename), function () { data: [ { amount: 0, + type: 'subscription', + proration: false, price: { product: 'prod_free' }, + period: { + start: 1635873600, + end: 1638465600, + }, }, ], }, @@ -703,7 +723,13 @@ module(basename(__filename), function () { data: [ { amount: 1200, + type: 'subscription', + proration: false, price: { product: 'prod_creator' }, + period: { + start: 20, + end: 30, + }, }, ], }, @@ -735,8 +761,8 @@ module(basename(__filename), function () { // Assert both subscription cycles have the correct period start and end assert.strictEqual(subscriptionCycles[0].periodStart, 1); assert.strictEqual(subscriptionCycles[0].periodEnd, 2); - assert.strictEqual(subscriptionCycles[1].periodStart, 2); - assert.strictEqual(subscriptionCycles[1].periodEnd, 3); + assert.strictEqual(subscriptionCycles[1].periodStart, 20); + assert.strictEqual(subscriptionCycles[1].periodEnd, 30); // Assert that the ledger has the correct sum of credits going in and out availableCredits = await sumUpCreditsLedger(dbAdapter, { diff --git a/packages/realm-server/tests/card-endpoints-test.ts b/packages/realm-server/tests/card-endpoints-test.ts index 169320be2d7..943e377e4f7 100644 --- a/packages/realm-server/tests/card-endpoints-test.ts +++ b/packages/realm-server/tests/card-endpoints-test.ts @@ -40,6 +40,55 @@ function parseSearchQuery(searchURL: URL) { return parse(searchURL.searchParams.toString()) as Record; } +// Create minimal valid PNG bytes for testing +function makeMinimalPng(): Uint8Array { + let signature = [137, 80, 78, 71, 13, 10, 26, 10]; + let ihdrData = new Uint8Array(13); + let ihdrView = new DataView(ihdrData.buffer); + ihdrView.setUint32(0, 1); // width + ihdrView.setUint32(4, 1); // height + ihdrData[8] = 8; // bit depth + ihdrData[9] = 2; // color type (RGB) + let ihdrChunk = buildPngChunk('IHDR', ihdrData); + let idatData = new Uint8Array([ + 0x08, 0xd7, 0x01, 0x00, 0x00, 0xff, 0xff, 0x00, 0x01, 0x00, 0x01, + ]); + let idatChunk = buildPngChunk('IDAT', idatData); + let iendChunk = buildPngChunk('IEND', new Uint8Array(0)); + let totalLength = + signature.length + ihdrChunk.length + idatChunk.length + iendChunk.length; + let png = new Uint8Array(totalLength); + let offset = 0; + png.set(signature, offset); + offset += signature.length; + png.set(ihdrChunk, offset); + offset += ihdrChunk.length; + png.set(idatChunk, offset); + offset += idatChunk.length; + png.set(iendChunk, offset); + return png; +} + +function buildPngChunk(type: string, data: Uint8Array): Uint8Array { + let chunk = new Uint8Array(4 + 4 + data.length + 4); + let view = new DataView(chunk.buffer); + view.setUint32(0, data.length); + for (let i = 0; i < 4; i++) { + chunk[4 + i] = type.charCodeAt(i); + } + chunk.set(data, 8); + let crc = 0xffffffff; + let crcData = chunk.slice(4, 8 + data.length); + for (let i = 0; i < crcData.length; i++) { + crc ^= crcData[i]!; + for (let j = 0; j < 8; j++) { + crc = crc & 1 ? (crc >>> 1) ^ 0xedb88320 : crc >>> 1; + } + } + view.setUint32(8 + data.length, (crc ^ 0xffffffff) >>> 0); + return chunk; +} + module(basename(__filename), function () { module('Realm-specific Endpoints | card URLs', function (hooks) { let realmURL = new URL('http://127.0.0.1:4444/test/'); @@ -193,7 +242,7 @@ module(basename(__filename), function () { test('includes FileDef resources for file links in included payload', async function (assert) { let { testRealm: realm, request } = getRealmSetup(); - let writes = new Map([ + let writes = new Map([ [ 'gallery.gts', ` @@ -237,9 +286,9 @@ module(basename(__filename), function () { }, }), ], - ['hero.png', 'mock hero image'], - ['first.png', 'mock first image'], - ['second.png', 'mock second image'], + ['hero.png', makeMinimalPng()], + ['first.png', makeMinimalPng()], + ['second.png', makeMinimalPng()], ]); await realm.writeMany(writes); @@ -275,8 +324,8 @@ module(basename(__filename), function () { assert.strictEqual(hero?.attributes?.name, 'hero.png'); assert.strictEqual(hero?.attributes?.contentType, 'image/png'); assert.deepEqual(hero?.meta?.adoptsFrom, { - module: `${baseRealm.url}file-api`, - name: 'FileDef', + module: `${baseRealm.url}png-image-def`, + name: 'PngDef', }); assert.deepEqual( diff --git a/packages/realm-server/tests/server-endpoints/download-realm-test.ts b/packages/realm-server/tests/server-endpoints/download-realm-test.ts index 22fb8c49992..3dfb9226dfc 100644 --- a/packages/realm-server/tests/server-endpoints/download-realm-test.ts +++ b/packages/realm-server/tests/server-endpoints/download-realm-test.ts @@ -1,6 +1,9 @@ import { module, test } from 'qunit'; import { basename } from 'path'; import { setupServerEndpointsTest, testRealm2URL } from './helpers'; +import { realmSecretSeed } from '../helpers'; +import { createJWT } from '../../utils/jwt'; +import { createURLSignatureSync } from '@cardstack/runtime-common/url-signature'; import type { Response } from 'superagent'; function binaryParser( @@ -86,4 +89,107 @@ module(`server-endpoints/${basename(__filename)}`, function (hooks) { 'explains missing realm', ); }); + + test('accepts auth token via query param with valid signature', async function (assert) { + // Remove public permissions to require authentication + await context.dbAdapter.execute( + `DELETE FROM realm_user_permissions WHERE realm_url = '${testRealm2URL.href}' AND username = '*'`, + ); + + // Add read permission for the test user + let testUser = '@test:localhost'; + await context.dbAdapter.execute( + `INSERT INTO realm_user_permissions (realm_url, username, read, write, realm_owner) + VALUES ('${testRealm2URL.href}', '${testUser}', true, false, false)`, + ); + + // Create a valid JWT token + let token = createJWT( + { user: testUser, sessionRoom: '!test:localhost' }, + realmSecretSeed, + ); + + // Build the URL and compute signature + let downloadURL = new URL('http://127.0.0.1:4445/_download-realm'); + downloadURL.searchParams.set('realm', testRealm2URL.href); + downloadURL.searchParams.set('token', token); + let sig = createURLSignatureSync(token, downloadURL); + + // Request with token and signature in query params + let response = await context.request2 + .get('/_download-realm') + .query({ realm: testRealm2URL.href, token, sig }) + .buffer(true) + .parse(binaryParser); + + let bodyPreview = response.body?.toString?.('utf8') ?? response.text ?? ''; + assert.strictEqual(response.status, 200, bodyPreview.slice(0, 200)); + assert.strictEqual( + response.headers['content-type'], + 'application/zip', + 'serves a zip archive with token auth', + ); + }); + + test('rejects token via query param without signature', async function (assert) { + // Remove public permissions to require authentication + await context.dbAdapter.execute( + `DELETE FROM realm_user_permissions WHERE realm_url = '${testRealm2URL.href}' AND username = '*'`, + ); + + let token = createJWT( + { user: '@test:localhost', sessionRoom: '!test:localhost' }, + realmSecretSeed, + ); + + let response = await context.request2 + .get('/_download-realm') + .query({ realm: testRealm2URL.href, token }); + + assert.strictEqual( + response.status, + 400, + 'returns bad request when signature is missing', + ); + }); + + test('rejects token via query param with invalid signature', async function (assert) { + // Remove public permissions to require authentication + await context.dbAdapter.execute( + `DELETE FROM realm_user_permissions WHERE realm_url = '${testRealm2URL.href}' AND username = '*'`, + ); + + let token = createJWT( + { user: '@test:localhost', sessionRoom: '!test:localhost' }, + realmSecretSeed, + ); + + let response = await context.request2 + .get('/_download-realm') + .query({ realm: testRealm2URL.href, token, sig: 'invalid-signature' }); + + assert.strictEqual( + response.status, + 401, + 'returns unauthorized for invalid signature', + ); + }); + + test('rejects invalid token via query param', async function (assert) { + // Remove public permissions to require authentication + await context.dbAdapter.execute( + `DELETE FROM realm_user_permissions WHERE realm_url = '${testRealm2URL.href}' AND username = '*'`, + ); + + // Invalid token with a signature (signature doesn't matter since token is invalid) + let response = await context.request2 + .get('/_download-realm') + .query({ realm: testRealm2URL.href, token: 'invalid-token', sig: 'any' }); + + assert.strictEqual( + response.status, + 401, + 'returns unauthorized for invalid token', + ); + }); }); diff --git a/packages/realm-server/tests/server-endpoints/stripe-webhook-test.ts b/packages/realm-server/tests/server-endpoints/stripe-webhook-test.ts index a34d556d2b9..e731aceceb3 100644 --- a/packages/realm-server/tests/server-endpoints/stripe-webhook-test.ts +++ b/packages/realm-server/tests/server-endpoints/stripe-webhook-test.ts @@ -153,7 +153,10 @@ module(`server-endpoints/${basename(__filename)}`, function () { data: [ { amount: 12, + type: 'subscription', + proration: false, price: { product: 'prod_creator' }, + period: { start: 1635873600, end: 1638465600 }, }, ], }, @@ -210,7 +213,10 @@ module(`server-endpoints/${basename(__filename)}`, function () { data: [ { amount: 0, + type: 'subscription', + proration: false, price: { product: 'prod_free' }, + period: { start: 1635873600, end: 1638465600 }, }, ], }, @@ -377,7 +383,10 @@ module(`server-endpoints/${basename(__filename)}`, function () { data: [ { amount: 12, + type: 'subscription', + proration: false, price: { product: 'prod_creator' }, + period: { start: 1635873600, end: 1638465600 }, }, ], }, @@ -561,7 +570,10 @@ module(`server-endpoints/${basename(__filename)}`, function () { data: [ { amount: 0, + type: 'subscription', + proration: false, price: { product: 'prod_free' }, + period: { start: 1635873600, end: 1638465600 }, }, ], }, diff --git a/packages/runtime-common/index-runner.ts b/packages/runtime-common/index-runner.ts index 8a28eae754f..a200d884961 100644 --- a/packages/runtime-common/index-runner.ts +++ b/packages/runtime-common/index-runner.ts @@ -58,6 +58,30 @@ const FILEDEF_CODE_REF_BY_EXTENSION: Record = { module: `${baseRealm.url}markdown-file-def`, name: 'MarkdownDef', }, + '.png': { + module: `${baseRealm.url}png-image-def`, + name: 'PngDef', + }, + '.jpg': { + module: `${baseRealm.url}jpg-image-def`, + name: 'JpgDef', + }, + '.jpeg': { + module: `${baseRealm.url}jpg-image-def`, + name: 'JpgDef', + }, + '.svg': { + module: `${baseRealm.url}svg-image-def`, + name: 'SvgDef', + }, + '.gif': { + module: `${baseRealm.url}gif-image-def`, + name: 'GifDef', + }, + '.webp': { + module: `${baseRealm.url}webp-image-def`, + name: 'WebpDef', + }, '.mismatch': { module: './filedef-mismatch', name: 'FileDef' }, }; const BASE_FILE_DEF_CODE_REF: ResolvedCodeRef = { diff --git a/packages/runtime-common/url-signature.ts b/packages/runtime-common/url-signature.ts new file mode 100644 index 00000000000..f2c06fcddc7 --- /dev/null +++ b/packages/runtime-common/url-signature.ts @@ -0,0 +1,65 @@ +/** + * Creates a signature binding a token to a specific URL. + * This prevents token reuse for other endpoints if intercepted. + * + * The signature is HMAC-SHA256(token, urlPath) where: + * - token is used as the HMAC key + * - urlPath is the pathname + search params (without the sig param) + */ + +// Browser implementation using Web Crypto API +export async function createURLSignature( + token: string, + url: URL, +): Promise { + // Create a copy of the URL without the signature param + let urlForSigning = new URL(url.href); + urlForSigning.searchParams.delete('sig'); + + let message = urlForSigning.pathname + urlForSigning.search; + let encoder = new TextEncoder(); + let keyData = encoder.encode(token); + let messageData = encoder.encode(message); + + let key = await crypto.subtle.importKey( + 'raw', + keyData, + { name: 'HMAC', hash: 'SHA-256' }, + false, + ['sign'], + ); + + let signature = await crypto.subtle.sign('HMAC', key, messageData); + let signatureArray = new Uint8Array(signature); + return btoa(String.fromCharCode(...signatureArray)) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, ''); // URL-safe base64 +} + +// Node.js implementation +export function createURLSignatureSync(token: string, url: URL): string { + // Dynamic import to avoid issues in browser + // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires + let crypto = require('crypto'); + + let urlForSigning = new URL(url.href); + urlForSigning.searchParams.delete('sig'); + + let message = urlForSigning.pathname + urlForSigning.search; + let signature = crypto + .createHmac('sha256', token) + .update(message) + .digest('base64url'); + + return signature; +} + +export function verifyURLSignature( + token: string, + url: URL, + providedSignature: string, +): boolean { + let expectedSignature = createURLSignatureSync(token, url); + return expectedSignature === providedSignature; +}