Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
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
29 changes: 29 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,35 @@ jobs:
# Job entrypoint. These had never run in CI before.
run: npm run -w @fuzefront/chat-service test

notification-service-tests:
name: Notification service (unit)
runs-on: ubuntu-latest
needs: build

steps:
- name: Checkout code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2

- name: Setup Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: '20.x'
cache: 'npm'

# notification-service is a root workspace, so `npm ci` installs its deps.
# It depends on no other workspace package, so nothing needs building first.
- name: Install workspace
run: npm ci

- name: Type-check notification-service
run: npm run -w @fuzefront/notification-service build

- name: Run notification-service unit tests
# Covers the routes (auth derivation, internal-publish fail-closed,
# read vs seen), the SSE hub (per-user isolation, stream cap, dead-client
# handling) and the auth middleware.
run: npm run -w @fuzefront/notification-service test

integration-tests:
name: Integration Tests
runs-on: ubuntu-latest
Expand Down
39 changes: 34 additions & 5 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,27 @@ jobs:
cache-from: type=gha
cache-to: type=gha,mode=max

- name: Build & push notification-service
# Root build context: the Dockerfile runs `npm ci --workspace=
# services/notification-service`, so it needs the root package.json and
# lockfile β€” not just services/notification-service/.
#
# continue-on-error matches the other auxiliary services, and the `id`
# is what lets the bump step below gate on the real outcome instead of
# pinning a tag for an image that was never pushed.
id: notification_image
continue-on-error: true
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: .
file: services/notification-service/Dockerfile
push: true
tags: |
ghcr.io/izzywdev/fuzefront-notification-service:${{ steps.tag.outputs.sha }}
ghcr.io/izzywdev/fuzefront-notification-service:latest
cache-from: type=gha
cache-to: type=gha,mode=max

- name: Build & push clock-app (built-in MF remote)
# Built-in reference Module-Federation remote, served same-origin at
# app.fuzefront.com/apps/clock/. Self-contained Dockerfile => context is clock-app/.
Expand Down Expand Up @@ -250,8 +271,14 @@ jobs:
# The expected-rewrite count moves with it so the guard stays exact.
CHAT_OK=0
if [ "${{ steps.chat_image.outcome }}" = "success" ]; then CHAT_OK=1; fi
EXPECTED=$((9 + CHAT_OK))
echo "chat-service image built: ${CHAT_OK} β€” expecting ${EXPECTED} tag rewrites"
# Same treatment for notification-service: continue-on-error, so only
# bump its tag when the image actually got pushed.
NOTIF_OK=0
if [ "${{ steps.notification_image.outcome }}" = "success" ]; then NOTIF_OK=1; fi
# 9 always-built images (incl. billing-service + provisioning-service,
# added in #442) plus the two continue-on-error ones.
EXPECTED=$((9 + CHAT_OK + NOTIF_OK))
echo "chat-service image built: ${CHAT_OK}, notification-service: ${NOTIF_OK} β€” expecting ${EXPECTED} tag rewrites"
mkdir -p ~/.ssh
printf '%s\n' "$SSH_KEY" > ~/.ssh/release_bump
chmod 600 ~/.ssh/release_bump
Expand Down Expand Up @@ -281,7 +308,7 @@ jobs:
# manually restored LF. Rewritten tag lines preserve the line's
# original ending so a CRLF file round-trips without a spurious
# whole-file diff.
awk -v sha="$SHA" -v chat="$CHAT_OK" '
awk -v sha="$SHA" -v chat="$CHAT_OK" -v notif="$NOTIF_OK" '
hot {
hot=0
if ($0 ~ /^[[:space:]]*tag:/) {
Expand All @@ -301,15 +328,17 @@ jobs:
/repository: ghcr\.io\/izzywdev\/fuzefront-billing-service\r?$/ { hot=1 }
/repository: ghcr\.io\/izzywdev\/fuzefront-provisioning-service\r?$/ { hot=1 }
chat == 1 && /repository: ghcr\.io\/izzywdev\/fuzefront-chat-service\r?$/ { hot=1 }
notif == 1 && /repository: ghcr\.io\/izzywdev\/fuzefront-notification-service\r?$/ { hot=1 }
{ print }
END { print n+0 > "/tmp/bump-count" }
' /tmp/vp-current.yaml > /tmp/vp-new.yaml
# Guard against silent no-ops: if the file layout drifts (quoted
# values, reordered keys, renamed registry path) the awk matches
# nothing and we would otherwise "succeed" while deploying stale
# tags (review finding). Exactly EXPECTED core-app tags must be
# rewritten: 9 always-built images, plus chat-service only when its
# continue-on-error build actually succeeded.
# rewritten: 9 always-built images, plus chat-service and
# notification-service only when their continue-on-error builds
# actually succeeded.
#
# billing-service and provisioning-service were added to this list
# late: both were BUILT on every release and pinned in values-prod,
Expand Down
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,11 @@ packages/*/dist/
# rather than as services/*/dist/ because email-service's dist IS tracked.
services/chat-service/dist/

# notification-service build output. Same reasoning as chat-service above:
# regenerated by `npm run -w @fuzefront/notification-service build` and baked in
# the image's build stage, so it is never committed.
services/notification-service/dist/

# Per-package local .npmrc workarounds (Windows native-binary installs only;
# the repo's os=linux pin must NOT be overridden in CI).
packages/*/.npmrc
Expand Down
11 changes: 11 additions & 0 deletions backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import dotenv from 'dotenv'
// Import routes
import authRoutes from './routes/auth'
import appsRoutes from './routes/apps'
import appInstallationsRoutes from './routes/app-installations'
import notificationProxyRoutes from './routes/notifications'
import organizationsRoutes from './routes/organizations'
import internalRoutes from './routes/internal'
import billingRoutes, { billingWebhookRouter } from './routes/billing'
Expand Down Expand Up @@ -291,6 +293,10 @@ try {

// Routes
app.use('/api/auth', authRoutes)
// Installation routes mount FIRST so `/installed` and `/:id/install*` resolve
// before appsRoutes' own handlers. Express falls through to appsRoutes for
// every path this router does not define.
app.use('/api/apps', appInstallationsRoutes)
app.use('/api/apps', appsRoutes)
app.use('/api/organizations', organizationsRoutes)
// Browser-facing flag reads, evaluated server-side against the AUTHENTICATED
Expand All @@ -306,6 +312,11 @@ app.use('/api/v1/billing', billingRoutes)
// proxy to the applications-service (routes/app-registry). Mount adapter first so CI
// env (no applications-service) is served from the local DB, then the proxy handles
// any requests the adapter passes through via next().
// Same-origin proxy to the notification-service. The shell's bell talks to
// /api/v1/notifications/*; this forwards it in-cluster. The service's
// /internal/* publish surface is blocked here β€” see routes/notifications.ts.
app.use('/api/v1/notifications', notificationProxyRoutes)

app.use('/api/v1/app-registry', appRegistryRoutes)
// App-registry proxy: browser -> backend -> fuzefront-applications:3003. The
// ingress `/api` catch-all + frontend nginx both route the manifest-shaped
Expand Down
225 changes: 225 additions & 0 deletions backend/src/migrations/017_app_scope_levels_and_installations.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,225 @@
import { Knex } from 'knex'

/**
* App install scopes.
*
* `apps` already answers two questions: who OWNS an app (`organization_id`,
* migration 006) and who may SEE it (`visibility`). Neither answers who an app
* may be INSTALLED for, and there is no installation record at all β€” an app is
* either registered to an org or it isn't.
*
* This migration adds the third question:
*
* apps.scope_level personal | organization | both
* app_installations one row per actual installation
*
* `scope_level` defaults to 'both'. Every app registered under the current
* org-centric model still works, and nothing about those apps forbids a
* personal install. Installation is not the authorization boundary β€” visibility,
* org membership and Permit still gate what a caller may see and do β€” so the
* permissive default does not widen access.
*
* NOTE ON NAMING: `apps.scope` already exists and means the Module-Federation
* remote container name (webpack scope). The new column is deliberately
* `scope_level`, never `scope`, so the two can never be confused.
*
* Shape is enforced in the DATABASE, not only in the route:
* - a CHECK constraint pins which anchor columns each (scope, install_mode)
* combination must and must not carry;
* - three PARTIAL UNIQUE indexes make "install" idempotent per target.
*
* The partial indexes are scoped to `status = 'active'` precisely so uninstall
* can be a soft revoke: an app may be uninstalled and reinstalled without
* colliding with a stale row.
*/

const INSTALL_SHAPE_CHECK = 'app_installations_shape_check'

export async function up(knex: Knex): Promise<void> {
// --- enums (idempotent) ---------------------------------------------------
await knex.raw(`
DO $$ BEGIN
CREATE TYPE app_scope_level_enum AS ENUM ('personal', 'organization', 'both');
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
`)
await knex.raw(`
DO $$ BEGIN
CREATE TYPE app_install_scope_enum AS ENUM ('personal', 'organization');
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
`)
await knex.raw(`
DO $$ BEGIN
CREATE TYPE app_install_mode_enum AS ENUM ('self', 'everyone');
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
`)
await knex.raw(`
DO $$ BEGIN
CREATE TYPE app_install_status_enum AS ENUM ('active', 'revoked');
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
`)

// --- apps.scope_level -----------------------------------------------------
if (!(await knex.schema.hasColumn('apps', 'scope_level'))) {
await knex.schema.alterTable('apps', table => {
table
.enum('scope_level', null, {
useNative: true,
existingType: true,
enumName: 'app_scope_level_enum',
})
.notNullable()
.defaultTo('both')
table.index(['scope_level'])
})
}

// --- app_installations ----------------------------------------------------
if (!(await knex.schema.hasTable('app_installations'))) {
await knex.schema.createTable('app_installations', table => {
table.uuid('id').primary().defaultTo(knex.raw('gen_random_uuid()'))

table
.uuid('app_id')
.notNullable()
.references('id')
.inTable('apps')
.onDelete('CASCADE')

table.enum('scope', null, {
useNative: true,
existingType: true,
enumName: 'app_install_scope_enum',
})
.notNullable()

// Personal installs are always 'self'; 'everyone' is only meaningful for
// an organization-scoped install.
table.enum('install_mode', null, {
useNative: true,
existingType: true,
enumName: 'app_install_mode_enum',
})
.notNullable()
.defaultTo('self')

// Anchors. Which of these is set is decided by (scope, install_mode) and
// enforced by INSTALL_SHAPE_CHECK below.
table
.uuid('user_id')
.nullable()
.references('id')
.inTable('users')
.onDelete('CASCADE')
table
.uuid('organization_id')
.nullable()
.references('id')
.inTable('organizations')
.onDelete('CASCADE')

table
.uuid('installed_by')
.notNullable()
.references('id')
.inTable('users')
.onDelete('CASCADE')

table.enum('status', null, {
useNative: true,
existingType: true,
enumName: 'app_install_status_enum',
})
.notNullable()
.defaultTo('active')

table.jsonb('settings').notNullable().defaultTo('{}')
table.timestamp('revoked_at').nullable()
table
.uuid('revoked_by')
.nullable()
.references('id')
.inTable('users')
.onDelete('SET NULL')

table.timestamps(true, true)

table.index(['app_id'])
table.index(['user_id'])
table.index(['organization_id'])
table.index(['status'])
})
}

// The shape constraint. Written raw because knex has no expression-CHECK API
// that survives the enum casts cleanly.
const shapeExists = await knex.raw(
`SELECT 1 FROM pg_constraint WHERE conname = ?`,
[INSTALL_SHAPE_CHECK]
)
if (shapeExists.rows.length === 0) {
await knex.raw(`
ALTER TABLE app_installations
ADD CONSTRAINT ${INSTALL_SHAPE_CHECK} CHECK (
(
scope = 'personal'
AND user_id IS NOT NULL
AND organization_id IS NULL
AND install_mode = 'self'
)
OR
(
scope = 'organization'
AND organization_id IS NOT NULL
AND (
(install_mode = 'self' AND user_id IS NOT NULL) OR
(install_mode = 'everyone' AND user_id IS NULL)
)
)
);
`)
}

// Idempotency per target. Partial on status='active' so a revoked row never
// blocks a reinstall.
await knex.raw(`
CREATE UNIQUE INDEX IF NOT EXISTS app_installations_personal_unique
ON app_installations (app_id, user_id)
WHERE scope = 'personal' AND status = 'active';
`)
await knex.raw(`
CREATE UNIQUE INDEX IF NOT EXISTS app_installations_org_everyone_unique
ON app_installations (app_id, organization_id)
WHERE scope = 'organization' AND install_mode = 'everyone' AND status = 'active';
`)
await knex.raw(`
CREATE UNIQUE INDEX IF NOT EXISTS app_installations_org_self_unique
ON app_installations (app_id, organization_id, user_id)
WHERE scope = 'organization' AND install_mode = 'self' AND status = 'active';
`)

// The hot read: "what is installed for me, here". Covers both the personal
// lookup and the org-self lookup.
await knex.raw(`
CREATE INDEX IF NOT EXISTS app_installations_effective_idx
ON app_installations (user_id, organization_id, status);
`)
}

export async function down(knex: Knex): Promise<void> {
await knex.schema.dropTableIfExists('app_installations')

if (await knex.schema.hasColumn('apps', 'scope_level')) {
await knex.schema.alterTable('apps', table => {
table.dropColumn('scope_level')
})
}

await knex.raw('DROP TYPE IF EXISTS app_install_status_enum')
await knex.raw('DROP TYPE IF EXISTS app_install_mode_enum')
await knex.raw('DROP TYPE IF EXISTS app_install_scope_enum')
await knex.raw('DROP TYPE IF EXISTS app_scope_level_enum')
}
Loading
Loading