Governed substrate for autonomous agents: scoped identity (passports), audited actions, MCP workspace. Infra IPs and secrets redacted for public release.
136 lines
5.7 KiB
JavaScript
136 lines
5.7 KiB
JavaScript
/**
|
||
* Migration 037: Space Invitations & view_id extension
|
||
* ADR-105
|
||
*
|
||
* 1. Adds `view_id` column to `user_access_permissions` table
|
||
* - INTEGER, nullable – references a view for granular per-view access control
|
||
* - No FK constraint because a `views` table does not yet exist;
|
||
* will be added in a future migration when the views feature lands.
|
||
*
|
||
* 2. Creates `space_invitations` table for email-based invite flow:
|
||
* - Token-based invitation links with expiry
|
||
* - Status lifecycle: pending -> accepted | expired | revoked
|
||
* - Partial index on token WHERE status = 'pending' for fast lookups
|
||
* - Index on invited_email for per-user queries
|
||
*/
|
||
|
||
export async function up(knex) {
|
||
const isPostgres = knex.client.config.client === 'pg' || knex.client.config.client === 'postgresql';
|
||
|
||
try {
|
||
// ========================================
|
||
// 1. ADD view_id TO user_access_permissions
|
||
// ========================================
|
||
const hasViewId = await knex.schema.hasColumn('user_access_permissions', 'view_id');
|
||
if (!hasViewId) {
|
||
await knex.schema.alterTable('user_access_permissions', (table) => {
|
||
table.integer('view_id').unsigned().nullable();
|
||
});
|
||
console.log('[Migration 037] Added view_id column to user_access_permissions');
|
||
} else {
|
||
console.log('[Migration 037] view_id column already exists on user_access_permissions, skipping');
|
||
}
|
||
|
||
// ========================================
|
||
// 2. CREATE space_invitations TABLE
|
||
// ========================================
|
||
const hasTable = await knex.schema.hasTable('space_invitations');
|
||
if (!hasTable) {
|
||
await knex.schema.createTable('space_invitations', (table) => {
|
||
table.increments('id').primary();
|
||
table.integer('space_id').unsigned().notNullable()
|
||
.references('id').inTable('spaces').onDelete('CASCADE');
|
||
table.integer('invited_by').unsigned().notNullable()
|
||
.references('id').inTable('users');
|
||
table.string('invited_email', 255).notNullable();
|
||
table.string('role', 20).notNullable().defaultTo('viewer');
|
||
table.string('token', 255).notNullable().unique();
|
||
table.string('status', 20).defaultTo('pending');
|
||
table.timestamp('expires_at').notNullable();
|
||
table.timestamp('created_at').defaultTo(knex.fn.now());
|
||
table.timestamp('accepted_at').nullable();
|
||
});
|
||
console.log('[Migration 037] Created space_invitations table');
|
||
|
||
// ========================================
|
||
// CHECK CONSTRAINT on status (Postgres only)
|
||
// ========================================
|
||
if (isPostgres) {
|
||
await knex.raw(`
|
||
ALTER TABLE space_invitations
|
||
ADD CONSTRAINT chk_space_invitations_status
|
||
CHECK (status IN ('pending', 'accepted', 'expired', 'revoked'))
|
||
`);
|
||
console.log('[Migration 037] Added CHECK constraint chk_space_invitations_status');
|
||
}
|
||
|
||
// ========================================
|
||
// INDEXES
|
||
// ========================================
|
||
if (isPostgres) {
|
||
// Partial index on token for fast pending-invitation lookups
|
||
await knex.raw(`
|
||
CREATE INDEX IF NOT EXISTS idx_space_invitations_token_pending
|
||
ON space_invitations (token)
|
||
WHERE status = 'pending'
|
||
`);
|
||
console.log('[Migration 037] Created partial index idx_space_invitations_token_pending');
|
||
|
||
// Regular index on invited_email
|
||
await knex.raw(`
|
||
CREATE INDEX IF NOT EXISTS idx_space_invitations_email
|
||
ON space_invitations (invited_email)
|
||
`);
|
||
console.log('[Migration 037] Created index idx_space_invitations_email');
|
||
} else {
|
||
// SQLite: regular indexes (no partial index support)
|
||
await knex.schema.alterTable('space_invitations', (table) => {
|
||
table.index('token', 'idx_space_invitations_token_pending');
|
||
table.index('invited_email', 'idx_space_invitations_email');
|
||
});
|
||
console.log('[Migration 037] Created indexes (SQLite)');
|
||
}
|
||
} else {
|
||
console.log('[Migration 037] space_invitations table already exists, skipping');
|
||
}
|
||
|
||
console.log('[Migration 037] Space invitations migration complete');
|
||
} catch (err) {
|
||
console.error('[Migration 037] Error during up migration:', err.message);
|
||
throw err;
|
||
}
|
||
}
|
||
|
||
export async function down(knex) {
|
||
const isPostgres = knex.client.config.client === 'pg' || knex.client.config.client === 'postgresql';
|
||
|
||
try {
|
||
// ========================================
|
||
// DROP space_invitations TABLE
|
||
// ========================================
|
||
if (isPostgres) {
|
||
// Drop CHECK constraint before table (Postgres only)
|
||
await knex.raw('ALTER TABLE space_invitations DROP CONSTRAINT IF EXISTS chk_space_invitations_status');
|
||
console.log('[Migration 037 DOWN] Dropped CHECK constraint chk_space_invitations_status');
|
||
}
|
||
|
||
await knex.schema.dropTableIfExists('space_invitations');
|
||
console.log('[Migration 037 DOWN] Dropped space_invitations table');
|
||
|
||
// ========================================
|
||
// DROP view_id FROM user_access_permissions
|
||
// ========================================
|
||
const hasViewId = await knex.schema.hasColumn('user_access_permissions', 'view_id');
|
||
if (hasViewId) {
|
||
await knex.schema.alterTable('user_access_permissions', (table) => {
|
||
table.dropColumn('view_id');
|
||
});
|
||
console.log('[Migration 037 DOWN] Dropped view_id column from user_access_permissions');
|
||
}
|
||
|
||
console.log('[Migration 037 DOWN] Space invitations rollback complete');
|
||
} catch (err) {
|
||
console.error('[Migration 037 DOWN] Error during down migration:', err.message);
|
||
throw err;
|
||
}
|
||
}
|