Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion backend/applications/src/app-registry/builtins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,12 @@ const BUILTIN_MANIFESTS: unknown[] = [
type: 'module-federation',
// Same-origin path, not an absolute app-host URL: identical value works on
// app.fuzefront.com, a tenant wildcard host, and localhost.
remoteEntry: '/apps/clock/assets/remoteEntry.js',
// NO `assets/` segment: clock-app/vite.config.ts sets `assetsDir: ''`
// (flat build output), so remoteEntry.js is served at the root of
// `/apps/clock/`, matching clock-app/nginx.conf's `location = /remoteEntry.js`.
// Existing rows are re-pointed by migration 010 (upsertBuiltin never
// touches an already-registered row, so this seed alone is inert on prod).
remoteEntry: '/apps/clock/remoteEntry.js',
scope: 'clockApp',
module: './ClockApp',
},
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { Knex } from 'knex'

/**
* Fixes an erroneous `/assets/` segment that 008_same_origin_federated_remotes.ts
* baked into the built-in `clock` app's remoteEntry: `/apps/clock/assets/remoteEntry.js`.
*
* WHY THIS IS WRONG: `clock-app/vite.config.ts` sets `assetsDir: ''` (flat
* build output) specifically so `remoteEntry.js` lands at the ROOT of the
* `base` path, not under a nested `assets/` directory — its own comment says
* so ("Output all chunks to dist/ directly (not dist/assets/) so
* remoteEntry.js is served at /apps/clock/remoteEntry.js"). `clock-app/nginx.conf`
* agrees: it declares `location = /remoteEntry.js` (exact match, no `assets/`
* prefix), not `location = /assets/remoteEntry.js`. 008's SAME_ORIGIN_REMOTES
* table conflated clock with fuzequality, which DOES nest under `assets/`
* (fuzequality/apps/web/vite.config.ts has no `assetsDir` override, so Vite's
* default `assets/` applies there) — but the two apps made different build
* choices, and the migration used one path shape for both. Result: remoteEntry.js
* 200s (nginx's `location /` SPA fallback serves index.html for the unmatched
* `/assets/remoteEntry.js` request) but the host's module loader gets HTML
* where it expected JS — the built-in reference app for federation was itself
* broken.
*
* WHY A MIGRATION IS REQUIRED, NOT JUST A builtins.ts EDIT: `upsertBuiltin` is
* `if (existing) return` — deliberately, so a seed rerun never clobbers
* operator state. `clock` is already registered (seeded by 008 with the wrong
* path), so the builtins.ts fix alone is inert on an existing database.
*
* Only rewrites a row whose remote_url is EXACTLY the known-wrong value 008
* produced. A relative URL never matches 008/009's REWRITABLE_HOST regex
* (anchored on `http(s)://`), so this migration checks the literal wrong
* relative path instead — anything else (an operator customisation, or an
* already-correct row) is left alone.
*/

const SLUG = 'clock'
const WRONG_ENTRY = '/apps/clock/assets/remoteEntry.js'
const CORRECT_ENTRY = '/apps/clock/remoteEntry.js'

export async function up(knex: Knex): Promise<void> {
const app = await knex('apps').where('slug', SLUG).first()
if (!app) return

const current: string = app.remote_url ?? ''
if (current !== WRONG_ENTRY) {
console.log(
`[010] ${SLUG}: remote_url is not the known-wrong value, leaving untouched (${current || '<empty>'})`
)
return
}

let manifest: Record<string, any> | null = null
try {
manifest = typeof app.manifest === 'string' ? JSON.parse(app.manifest) : app.manifest
} catch {
manifest = null
}

const update: Record<string, unknown> = {
remote_url: CORRECT_ENTRY,
url: CORRECT_ENTRY,
updated_at: new Date(),
}

if (manifest?.integration) {
manifest.integration.remoteEntry = CORRECT_ENTRY
update.manifest = JSON.stringify(manifest)
} else {
console.log(
`[010] ${SLUG}: columns updated but manifest jsonb was unreadable — the host reads the manifest, so this row still needs a re-register`
)
}

await knex('apps').where('id', app.id).update(update)
console.log(`[010] ${SLUG}: ${current} → ${CORRECT_ENTRY}`)
}

export async function down(_knex: Knex): Promise<void> {
// Intentionally irreversible: the previous value is known-broken (nginx has
// no /assets/remoteEntry.js to serve it from). Rolling back would restore a
// URL that is known not to load.
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
/**
* Unit tests for migrations/010_clock_remoteentry_assets_segment_fix.ts —
* corrects the erroneous `/assets/` segment 008_same_origin_federated_remotes.ts
* baked into the built-in `clock` app's remoteEntry.
*
* No DB, no network — pure unit tests using an in-memory fake knex builder
* that supports exactly the two call shapes the migration issues:
* knex('apps').where('slug', slug).first()
* knex('apps').where('id', id).update(patch)
*/

import { up } from '../src/migrations/010_clock_remoteentry_assets_segment_fix'

type AppRow = {
id: string
slug: string
remote_url: string | null
url: string | null
manifest: any
updated_at?: Date
}

function makeFakeKnex(rows: AppRow[]) {
const table = new Map<string, AppRow>(rows.map((r) => [r.id, r]))
const updateCalls: Array<{ id: string; patch: Record<string, unknown> }> = []

function knex(tableName: string) {
if (tableName !== 'apps') throw new Error(`unexpected table: ${tableName}`)

return {
where(col: string, val: string) {
return {
first: async () => {
if (col !== 'slug') throw new Error(`unexpected where col in first(): ${col}`)
return [...table.values()].find((r) => r.slug === val)
},
update: async (patch: Record<string, unknown>) => {
if (col !== 'id') throw new Error(`unexpected where col in update(): ${col}`)
const row = table.get(val)
if (!row) return 0
Object.assign(row, patch)
updateCalls.push({ id: val, patch })
return 1
},
}
},
}
}

return { knex: knex as any, table, updateCalls }
}

const WRONG_ENTRY = '/apps/clock/assets/remoteEntry.js'
const CORRECT_ENTRY = '/apps/clock/remoteEntry.js'

function clockRow(overrides: Partial<AppRow> = {}): AppRow {
return {
id: 'app-1',
slug: 'clock',
remote_url: WRONG_ENTRY,
url: WRONG_ENTRY,
manifest: {
slug: 'clock',
integration: { type: 'module-federation', remoteEntry: WRONG_ENTRY, scope: 'clockApp' },
},
...overrides,
}
}

describe('migration 010: clock remoteEntry assets-segment fix', () => {
test('rewrites remote_url, url, and manifest.integration.remoteEntry for the known-wrong value', async () => {
const { knex, table, updateCalls } = makeFakeKnex([clockRow()])

await up(knex)

const row = table.get('app-1')!
expect(row.remote_url).toBe(CORRECT_ENTRY)
expect(row.url).toBe(CORRECT_ENTRY)
const manifest = typeof row.manifest === 'string' ? JSON.parse(row.manifest) : row.manifest
expect(manifest.integration.remoteEntry).toBe(CORRECT_ENTRY)
expect(updateCalls).toHaveLength(1)
})

test('does not touch slug', async () => {
const { knex, table } = makeFakeKnex([clockRow()])
await up(knex)
expect(table.get('app-1')!.slug).toBe('clock')
})

test('is a no-op when no clock row exists (fresh DB, builtins.ts seeds it directly)', async () => {
const { knex, updateCalls } = makeFakeKnex([])
await expect(up(knex)).resolves.toBeUndefined()
expect(updateCalls).toHaveLength(0)
})

test('idempotent: re-running after the row is already correct is a no-op', async () => {
const { knex, table, updateCalls } = makeFakeKnex([
clockRow({ remote_url: CORRECT_ENTRY, url: CORRECT_ENTRY }),
])

await up(knex)

expect(updateCalls).toHaveLength(0)
expect(table.get('app-1')!.remote_url).toBe(CORRECT_ENTRY)
})

test('leaves an operator-customised remote_url untouched', async () => {
const customUrl = '/apps/clock-canary/remoteEntry.js'
const { knex, table, updateCalls } = makeFakeKnex([
clockRow({ remote_url: customUrl, url: customUrl }),
])

await up(knex)

expect(updateCalls).toHaveLength(0)
expect(table.get('app-1')!.remote_url).toBe(customUrl)
})

test('does not rewrite unrelated apps (only touches slug=clock)', async () => {
const { knex, table, updateCalls } = makeFakeKnex([
{
id: 'app-2',
slug: 'fuzequality',
remote_url: '/apps/fuzequality/assets/remoteEntry.js',
url: '/apps/fuzequality/assets/remoteEntry.js',
manifest: null,
},
])

await up(knex)

expect(updateCalls).toHaveLength(0)
expect(table.get('app-2')!.remote_url).toBe('/apps/fuzequality/assets/remoteEntry.js')
})

test('handles a manifest stored as a JSON string (jsonb round-trip) and updates it', async () => {
const { knex, table } = makeFakeKnex([
clockRow({
manifest: JSON.stringify({
slug: 'clock',
integration: { type: 'module-federation', remoteEntry: WRONG_ENTRY, scope: 'clockApp' },
}),
}),
])

await up(knex)

const row = table.get('app-1')!
const parsed = typeof row.manifest === 'string' ? JSON.parse(row.manifest) : row.manifest
expect(parsed.integration.remoteEntry).toBe(CORRECT_ENTRY)
})

test('rewrites columns but does not crash when manifest jsonb is unreadable', async () => {
const { knex, table } = makeFakeKnex([clockRow({ manifest: '{not valid json' })])

await expect(up(knex)).resolves.toBeUndefined()

const row = table.get('app-1')!
expect(row.remote_url).toBe(CORRECT_ENTRY)
expect(row.url).toBe(CORRECT_ENTRY)
})
})
2 changes: 1 addition & 1 deletion services/app-registry-service/seed/clock.manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
"builtin": true,
"integration": {
"type": "module-federation",
"remoteEntry": "https://app.fuzefront.com/apps/clock/assets/remoteEntry.js",
"remoteEntry": "/apps/clock/remoteEntry.js",
"scope": "clockApp",
"module": "./ClockApp"
},
Expand Down
Loading