Governed substrate for autonomous agents: scoped identity (passports), audited actions, MCP workspace. Infra IPs and secrets redacted for public release.
461 lines
17 KiB
JavaScript
461 lines
17 KiB
JavaScript
// ADR-0079 — Personal Space Starter Pack v1
|
|
// Idempotent post-signup provisioning: 6 starter tables + Welcome Dashboard widget
|
|
// + Tor first-message conversation. Wired from /auth/register via AuthService.registerUser().
|
|
|
|
import { dbGet, dbAll, dbRun, withTransactionAsync, toBool, safeJsonParse } from '../../database/connection.js';
|
|
import { authLogger } from '../../utils/logger.js';
|
|
import { generateBaseId } from '../../utils/baseId.js';
|
|
import {
|
|
STARTER_PROJECT_NAME,
|
|
STARTER_PROJECT_ICON,
|
|
STARTER_TABLES,
|
|
TIER_A_AGENT_SLUGS,
|
|
TIER_B_AGENT_SLUGS,
|
|
TIER_B_UNLOCK_PROMOS,
|
|
WELCOME_WIDGET_PRESET,
|
|
WELCOME_WIDGET_TITLE,
|
|
WELCOME_WIDGET_POSITION,
|
|
FEATURE_FLAG_KEY,
|
|
buildStarterTableSeeds
|
|
} from './starterPackCatalog.js';
|
|
|
|
const AGENTS_TABLE_ID = 1784;
|
|
|
|
/**
|
|
* Read the kill-switch flag from _app_settings.
|
|
* Default: enabled (fail-open — provisioning is best-effort).
|
|
*/
|
|
async function isFeatureEnabled() {
|
|
try {
|
|
const row = await dbGet('SELECT value FROM _app_settings WHERE key = ?', [FEATURE_FLAG_KEY]);
|
|
if (!row) return true;
|
|
const v = row.value;
|
|
if (typeof v === 'boolean') return v;
|
|
if (typeof v === 'string') return v === 'true' || v === '"true"';
|
|
return safeJsonParse(v, true) === true;
|
|
} catch (err) {
|
|
authLogger.warn({ err }, '[StarterPack] feature_flag read failed — defaulting to enabled');
|
|
return true;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Locate the user's personal Space + its default "Home" project.
|
|
* Returns null if the personal space is missing (autoCreateDefaultProjects must run first).
|
|
*/
|
|
async function findPersonalHome(userId) {
|
|
const space = await dbGet(
|
|
`SELECT id, name FROM spaces WHERE owner_id = ? AND type = ? ORDER BY id LIMIT 1`,
|
|
[userId, 'personal']
|
|
);
|
|
if (!space) return null;
|
|
|
|
// Pick the lowest-id project — autoCreateDefaultProjects creates exactly one ("My Tasks").
|
|
const project = await dbGet(
|
|
`SELECT id, name FROM projects WHERE space_id = ? ORDER BY id LIMIT 1`,
|
|
[space.id]
|
|
);
|
|
if (!project) return null;
|
|
return { spaceId: space.id, projectId: project.id, projectName: project.name };
|
|
}
|
|
|
|
/**
|
|
* Idempotency probe: if any of the named starter tables already exists in the project,
|
|
* we treat the pack as provisioned and return true.
|
|
*/
|
|
async function isAlreadyProvisioned(projectId) {
|
|
const names = STARTER_TABLES.map(t => t.name);
|
|
const rows = await dbAll(
|
|
`SELECT name FROM universal_tables
|
|
WHERE project_id = ? AND deleted_at IS NULL AND name = ANY(?::text[])`,
|
|
[projectId, names]
|
|
);
|
|
return rows.length > 0;
|
|
}
|
|
|
|
/**
|
|
* Create one starter table inside the transaction.
|
|
*/
|
|
async function createStarterTable(trx, projectId, spec) {
|
|
const tblResult = await trx.run(
|
|
`INSERT INTO universal_tables (project_id, name, description, icon, is_system, show_in_nav, table_type)
|
|
VALUES (?, ?, ?, ?, ?, 1, 'starter_pack')`,
|
|
[projectId, spec.name, spec.description ?? null, spec.icon ?? null, toBool(false)]
|
|
);
|
|
const tableId = tblResult.lastInsertRowid || tblResult.lastID;
|
|
|
|
for (const col of spec.columns) {
|
|
await trx.run(
|
|
`INSERT INTO table_columns (table_id, column_name, display_name, type, config,
|
|
order_index, is_required, is_system)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
[
|
|
tableId,
|
|
col.name,
|
|
col.display ?? col.name,
|
|
col.type,
|
|
col.config ? JSON.stringify(col.config) : null,
|
|
col.order ?? 0,
|
|
toBool(!!col.is_required),
|
|
toBool(!!col.is_system)
|
|
]
|
|
);
|
|
}
|
|
return tableId;
|
|
}
|
|
|
|
/**
|
|
* Seed sample rows into a freshly-created starter table. Dates are computed
|
|
* per call (in buildStarterTableSeeds) so they stay anchored to "today" at
|
|
* registration time. Skips silently if the catalog has no seed for the slug.
|
|
*
|
|
* Rows are inserted with `created_by = userId` so the user owns them and can
|
|
* delete freely; an autogenerated 8-char `base_id` matches the canonical
|
|
* shape used by tableRowCreateController.
|
|
*/
|
|
async function seedStarterTableRows(trx, slug, tableId, userId, seedsBySlug) {
|
|
if (!slug) return 0;
|
|
const rows = seedsBySlug[slug];
|
|
if (!Array.isArray(rows) || rows.length === 0) return 0;
|
|
|
|
let inserted = 0;
|
|
for (const row of rows) {
|
|
await trx.run(
|
|
`INSERT INTO table_rows (table_id, base_id, data, created_by, created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, NOW(), NOW())`,
|
|
[tableId, generateBaseId(), JSON.stringify(row), userId]
|
|
);
|
|
inserted += 1;
|
|
}
|
|
return inserted;
|
|
}
|
|
|
|
/**
|
|
* Pin one `table_view` preset widget per starter table on the HOME project's
|
|
* default dashboard, stacked vertically. ProjectService.createProject already
|
|
* created the project dashboard ("My Tasks Dashboard" → renamed implicitly via
|
|
* project rename) — we just look it up and INSERT widgets.
|
|
*
|
|
* Why this is separate from pinWelcomeWidget: the Welcome banner lives on the
|
|
* SPACE dashboard (where SpaceDashboardPage lands), but the per-table nav
|
|
* entries the user expects under "Home" in the sidebar are driven by widget
|
|
* rows attached to the PROJECT dashboard. Without these, the project page
|
|
* renders empty even though the tables exist in _databases.
|
|
*
|
|
* Idempotent: skips any (dashboard_id, table_id) pair already wired up.
|
|
*
|
|
* @param {object} trx
|
|
* @param {number} projectId - Home project id
|
|
* @param {Array<{name:string,id:number}>} tableIds - output of createStarterTable loop
|
|
* @param {number} userId
|
|
*/
|
|
async function pinStarterTableWidgets(trx, projectId, tableIds, userId) {
|
|
const dashboard = await trx.get(
|
|
`SELECT id FROM dashboards
|
|
WHERE project_id = ? AND is_default = ?
|
|
ORDER BY id ASC LIMIT 1`,
|
|
[projectId, toBool(true)]
|
|
);
|
|
if (!dashboard?.id) {
|
|
authLogger.warn({ projectId }, '[StarterPack] project dashboard missing — table widgets skipped');
|
|
return { dashboardId: null, widgetIds: [] };
|
|
}
|
|
const dashboardId = dashboard.id;
|
|
|
|
const widgetIds = [];
|
|
let order = 0;
|
|
for (const t of tableIds) {
|
|
const existing = await trx.get(
|
|
`SELECT id FROM widgets
|
|
WHERE dashboard_id = ? AND widget_type = 'preset' AND preset_name = 'table_view'
|
|
AND (config::jsonb->>'table_id')::int = ?
|
|
LIMIT 1`,
|
|
[dashboardId, t.id]
|
|
);
|
|
if (existing) {
|
|
widgetIds.push(existing.id);
|
|
order += 1;
|
|
continue;
|
|
}
|
|
|
|
const position = { x: 0, y: order * 4, w: 12, h: 4 };
|
|
const res = await trx.run(
|
|
`INSERT INTO widgets (dashboard_id, widget_type, preset_name, title, icon,
|
|
config, position, is_visible, order_index, owner_kind, owner_id, created_by)
|
|
VALUES (?, 'preset', 'table_view', ?, '📋', ?, ?, 1, ?, 'dashboard', ?, ?)`,
|
|
[
|
|
dashboardId,
|
|
t.name,
|
|
JSON.stringify({ table_id: t.id }),
|
|
JSON.stringify(position),
|
|
order,
|
|
dashboardId,
|
|
userId
|
|
]
|
|
);
|
|
widgetIds.push(res.lastInsertRowid || res.lastID);
|
|
order += 1;
|
|
}
|
|
return { dashboardId, widgetIds };
|
|
}
|
|
|
|
/**
|
|
* Pin the Welcome widget on the SPACE's default dashboard.
|
|
*
|
|
* Why the space dashboard, not the project's: SpaceDashboardPage hits
|
|
* `/api/v3/spaces/:id/dashboard` → `getSpaceDashboard()`, which returns the
|
|
* dashboard row where `space_id = :id AND is_default = true` (lazily created
|
|
* by createSpace as "<Space> Overview"). When the user clicks the Personal
|
|
* Space tile after register/login they land on that dashboard — so the
|
|
* welcome experience must live there. The project's own dashboard ("My Tasks
|
|
* Dashboard") is only reachable one level deeper and was rendering empty.
|
|
*
|
|
* @param {object} trx - active transaction handle
|
|
* @param {number} spaceId
|
|
* @param {number} userId
|
|
* @param {Record<string, number>} starterTablesMap - slug→tableId map consumed by
|
|
* the frontend WelcomeDashboardWidget to enable the per-table CTA cards. Required.
|
|
*/
|
|
async function pinWelcomeWidget(trx, spaceId, userId, starterTablesMap) {
|
|
// Reuse the space-level default dashboard created by createSpace.
|
|
let dashboard = await trx.get(
|
|
`SELECT id FROM dashboards
|
|
WHERE space_id = ? AND project_id IS NULL
|
|
ORDER BY id ASC LIMIT 1`,
|
|
[spaceId]
|
|
);
|
|
let dashboardId = dashboard?.id;
|
|
|
|
if (!dashboardId) {
|
|
const dRes = await trx.run(
|
|
`INSERT INTO dashboards (user_id, space_id, project_id, name, description, icon, is_default, order_index)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, 0)`,
|
|
[null, spaceId, null, 'Home', null, '🏠', toBool(true)]
|
|
);
|
|
dashboardId = dRes.lastInsertRowid || dRes.lastID;
|
|
}
|
|
|
|
// Skip pinning if a welcome widget is already there (idempotent).
|
|
const existing = await trx.get(
|
|
`SELECT id FROM widgets WHERE dashboard_id = ? AND preset_name = ? LIMIT 1`,
|
|
[dashboardId, WELCOME_WIDGET_PRESET]
|
|
);
|
|
if (existing) return { dashboardId, widgetId: existing.id };
|
|
|
|
// Shift any existing widgets down so the welcome banner sits at the top.
|
|
await trx.run(
|
|
`UPDATE widgets
|
|
SET position = jsonb_set(position::jsonb, '{y}', to_jsonb(COALESCE((position::jsonb->>'y')::int, 0) + ?))::text,
|
|
order_index = order_index + 1
|
|
WHERE dashboard_id = ?`,
|
|
[WELCOME_WIDGET_POSITION.h, dashboardId]
|
|
);
|
|
|
|
const wRes = await trx.run(
|
|
`INSERT INTO widgets (dashboard_id, widget_type, preset_name, title, icon,
|
|
config, position, is_visible, order_index, owner_kind, owner_id, created_by)
|
|
VALUES (?, 'preset', ?, ?, '🏠', ?, ?, 1, 0, 'dashboard', ?, ?)`,
|
|
[
|
|
dashboardId,
|
|
WELCOME_WIDGET_PRESET,
|
|
WELCOME_WIDGET_TITLE,
|
|
JSON.stringify({
|
|
adr: 'ADR-0079',
|
|
copy_source: 'shared/starter-pack-copy.json',
|
|
starter_tables_map: starterTablesMap
|
|
}),
|
|
JSON.stringify(WELCOME_WIDGET_POSITION),
|
|
dashboardId,
|
|
userId
|
|
]
|
|
);
|
|
return { dashboardId, widgetId: wRes.lastInsertRowid || wRes.lastID };
|
|
}
|
|
|
|
/**
|
|
* Spawn a Tor conversation and post the welcome message.
|
|
* The 2s display delay is a client-side UX detail — backend writes once, frontend animates on load.
|
|
*/
|
|
async function scheduleTorFirstMessage(trx, userId, spaceId) {
|
|
const tor = await trx.get(
|
|
`SELECT id, data->>'name' AS name FROM table_rows
|
|
WHERE table_id = ? AND data->>'agent_slug' = ? LIMIT 1`,
|
|
[AGENTS_TABLE_ID, 'tor']
|
|
);
|
|
if (!tor) {
|
|
authLogger.warn({ userId }, '[StarterPack] Tor agent row missing — first-message skipped');
|
|
return null;
|
|
}
|
|
|
|
const convRes = await trx.run(
|
|
`INSERT INTO conversations (type, title, created_by, space_id, agent_id, agent_table_id, settings)
|
|
VALUES ('ai_chat', ?, ?, ?, ?, ?, ?)`,
|
|
['Tor', userId, spaceId, tor.id, AGENTS_TABLE_ID, JSON.stringify({ adr: 'ADR-0079', starter_pack: true })]
|
|
);
|
|
const conversationId = convRes.lastInsertRowid || convRes.lastID;
|
|
|
|
// Inline the copy so the message is self-contained even if the JSON moves.
|
|
const torContent = [
|
|
'Hi 👋 I\'m Tor.',
|
|
'',
|
|
'I\'ll show you around in 5 minutes. You\'ve got six shelves here — daily log, goals, habits, people, ideas, wishlist. Each one is seeded with a few sample rows so you can see how they work; delete them any time.',
|
|
'',
|
|
'Where do you want to start — the journal, or a goal? If you\'re not even sure what a CRM is for you, just say so and I\'ll explain honestly.'
|
|
].join('\n');
|
|
|
|
const msgRes = await trx.run(
|
|
`INSERT INTO messages (conversation_id, sender_id, sender_type, role, content, content_type, agent_id)
|
|
VALUES (?, ?, 'agent', 'assistant', ?, 'text', ?)`,
|
|
[conversationId, null, torContent, tor.id]
|
|
);
|
|
const messageId = msgRes.lastInsertRowid || msgRes.lastID;
|
|
|
|
await trx.run(
|
|
`UPDATE conversations
|
|
SET last_message_id = ?, last_message_at = NOW(),
|
|
last_message_preview = ?, messages_count = 1
|
|
WHERE id = ?`,
|
|
[messageId, torContent.slice(0, 120), conversationId]
|
|
);
|
|
|
|
return { conversationId, messageId };
|
|
}
|
|
|
|
/**
|
|
* Rename the auto-created project to "Home" if it still has its default name,
|
|
* and flip `settings.is_starter_home = true` so the frontend can tell this
|
|
* project apart from user-created ones (Welcome widget discovery / future
|
|
* marketplace gating). Convention only — no schema change.
|
|
*/
|
|
async function renameToHome(trx, projectId, currentName) {
|
|
// Always flip the starter flag — idempotent JSON merge.
|
|
await trx.run(
|
|
`UPDATE projects
|
|
SET settings = jsonb_set(
|
|
COALESCE(NULLIF(settings, '')::jsonb, '{}'::jsonb),
|
|
'{is_starter_home}',
|
|
'true'::jsonb,
|
|
true
|
|
)::text,
|
|
updated_at = CURRENT_TIMESTAMP
|
|
WHERE id = ?`,
|
|
[projectId]
|
|
);
|
|
|
|
// Only rename the canonical auto-create name; don't clobber anything else.
|
|
if (currentName === STARTER_PROJECT_NAME) return;
|
|
if (currentName !== 'My Tasks') return;
|
|
await trx.run(
|
|
`UPDATE projects SET name = ?, icon = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
|
|
[STARTER_PROJECT_NAME, STARTER_PROJECT_ICON, projectId]
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Apply a Tier-B unlock to the user (called from /auth/register when promo_code matches).
|
|
* Stores unlocked slugs inside users.agent_config JSONB.
|
|
*
|
|
* @param {number} userId
|
|
* @param {string|null} promoCode
|
|
* @returns {Promise<string[]>} List of unlocked slugs (empty if promo doesn't match).
|
|
*/
|
|
export async function applyPromoUnlock(userId, promoCode) {
|
|
if (!promoCode) return [];
|
|
const normalized = String(promoCode).trim().toUpperCase();
|
|
if (!TIER_B_UNLOCK_PROMOS.includes(normalized)) return [];
|
|
|
|
try {
|
|
await dbRun(
|
|
`UPDATE users
|
|
SET agent_config = jsonb_set(
|
|
COALESCE(agent_config, '{}'::jsonb),
|
|
'{unlocked_agent_slugs}',
|
|
to_jsonb(?::text[]),
|
|
true
|
|
),
|
|
updated_at = CURRENT_TIMESTAMP
|
|
WHERE id = ?`,
|
|
[TIER_B_AGENT_SLUGS, userId]
|
|
);
|
|
authLogger.info({ userId, promoCode: normalized, unlocked: TIER_B_AGENT_SLUGS }, '[StarterPack] Tier-B coding pack unlocked');
|
|
return [...TIER_B_AGENT_SLUGS];
|
|
} catch (err) {
|
|
authLogger.error({ err, userId, promoCode: normalized }, '[StarterPack] promo unlock failed');
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Main entry — provision the starter pack for a freshly-registered user.
|
|
* Idempotent: safe to re-run; no-ops when tables already exist.
|
|
*
|
|
* Failures are logged but DO NOT throw — registration must never fail because of cosmetics.
|
|
*/
|
|
export async function applyStarterPack(userId, _userName = null) {
|
|
if (!(await isFeatureEnabled())) {
|
|
authLogger.info({ userId }, '[StarterPack] feature flag off — skipping');
|
|
return { skipped: true, reason: 'feature_disabled' };
|
|
}
|
|
|
|
try {
|
|
const home = await findPersonalHome(userId);
|
|
if (!home) {
|
|
authLogger.warn({ userId }, '[StarterPack] personal space not found — autoCreateDefaultProjects must run first');
|
|
return { skipped: true, reason: 'no_personal_space' };
|
|
}
|
|
|
|
if (await isAlreadyProvisioned(home.projectId)) {
|
|
authLogger.info({ userId, projectId: home.projectId }, '[StarterPack] already provisioned — skipping');
|
|
return { skipped: true, reason: 'already_provisioned' };
|
|
}
|
|
|
|
const result = await withTransactionAsync(async (trx) => {
|
|
await renameToHome(trx, home.projectId, home.projectName);
|
|
|
|
const seedsBySlug = buildStarterTableSeeds();
|
|
const tableIds = [];
|
|
const starterTablesMap = {};
|
|
let seededRows = 0;
|
|
for (const spec of STARTER_TABLES) {
|
|
const tid = await createStarterTable(trx, home.projectId, spec);
|
|
tableIds.push({ name: spec.name, id: tid });
|
|
if (spec.slug) {
|
|
starterTablesMap[spec.slug] = tid;
|
|
seededRows += await seedStarterTableRows(trx, spec.slug, tid, userId, seedsBySlug);
|
|
}
|
|
}
|
|
|
|
const tableWidgets = await pinStarterTableWidgets(trx, home.projectId, tableIds, userId);
|
|
const widget = await pinWelcomeWidget(trx, home.spaceId, userId, starterTablesMap);
|
|
const tor = await scheduleTorFirstMessage(trx, userId, home.spaceId);
|
|
|
|
return { tableIds, tableWidgets, widget, tor, seededRows };
|
|
});
|
|
|
|
authLogger.info(
|
|
{
|
|
userId,
|
|
spaceId: home.spaceId,
|
|
projectId: home.projectId,
|
|
tables: result.tableIds.length,
|
|
seededRows: result.seededRows || 0,
|
|
tableWidgets: result.tableWidgets?.widgetIds?.length || 0,
|
|
widgetId: result.widget?.widgetId,
|
|
torConversationId: result.tor?.conversationId
|
|
},
|
|
'[StarterPack] provisioned (ADR-0079)'
|
|
);
|
|
|
|
return { success: true, ...result, spaceId: home.spaceId, projectId: home.projectId };
|
|
} catch (err) {
|
|
authLogger.error({ err, userId }, '[StarterPack] provisioning failed (non-fatal)');
|
|
return { skipped: true, reason: 'error', error: err.message };
|
|
}
|
|
}
|
|
|
|
// Internal exports for tests.
|
|
export const __test = {
|
|
isFeatureEnabled,
|
|
findPersonalHome,
|
|
isAlreadyProvisioned
|
|
};
|