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
200 changes: 104 additions & 96 deletions server/public/onboarding.html

Large diffs are not rendered by default.

17 changes: 17 additions & 0 deletions server/src/addie/jobs/job-definitions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,8 @@ import { runOrphanOrgAudit, type OrphanOrgAuditResult } from './orphan-org-audit
import { NotificationDatabase } from '../../db/notification-db.js';
import { notifyUser } from '../../notifications/notification-service.js';
import { createLogger } from '../../logger.js';
import { getAuthorizationEnforcementWorkos } from '../../auth/workos-client.js';
import { runOrganizationOnboardingReconciliationJob } from './organization-onboarding-reconciliation.js';

const logger = createLogger('job-definitions');

Expand Down Expand Up @@ -131,6 +133,20 @@ async function runContentCuratorJob() {
* Call this on startup before starting jobs.
*/
export function registerAllJobs(): void {
jobScheduler.register({
name: 'organization-onboarding-reconciliation',
description: 'Resume fenced exact-credential organization onboarding operations',
interval: { value: 1, unit: 'minutes' },
initialDelay: { value: 30, unit: 'seconds' },
executionTimeoutMs: 3 * 60 * 1000,
runner: () => runOrganizationOnboardingReconciliationJob(
getAuthorizationEnforcementWorkos(),
2,
10,
),
shouldLogResult: (result) => result.attempted > 0 || result.manualQueued > 0,
});

// Document indexer - indexes Google Docs tracked by committees
jobScheduler.register({
name: 'document-indexer',
Expand Down Expand Up @@ -1012,6 +1028,7 @@ export function registerAllJobs(): void {
* Job names for conditional startup (e.g., Moltbook jobs only if API key is set)
*/
export const JOB_NAMES = {
ORGANIZATION_ONBOARDING_RECONCILIATION: 'organization-onboarding-reconciliation',
DOCUMENT_INDEXER: 'document-indexer',
SUMMARY_GENERATOR: 'summary-generator',
RELATIONSHIP_ORCHESTRATOR: 'relationship-orchestrator',
Expand Down
55 changes: 55 additions & 0 deletions server/src/addie/jobs/organization-onboarding-reconciliation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
/**
* Recover safe onboarding operations and durably surface ambiguous ones.
*
* Manual-reconciliation rows must not be replayed automatically because the
* provider/local authority outcome is uncertain. The recurring scan creates a
* deduplicated operator escalation instead, so those rows cannot remain silent.
*/
import type { WorkOS } from '@workos-inc/node';
import { getAuthorizationEnforcementWorkos } from '../../auth/workos-client.js';
import { createEscalation } from '../../db/escalation-db.js';
import {
listManualOrganizationOnboardingOperations,
reconcilePendingOrganizationOnboarding,
type OrganizationOnboardingReconciliationResult,
} from '../../services/organization-bootstrap.js';

export type OrganizationOnboardingRecoveryJobResult =
OrganizationOnboardingReconciliationResult & {
manualScanned: number;
manualQueued: number;
};

export async function runOrganizationOnboardingReconciliationJob(
workos: WorkOS = getAuthorizationEnforcementWorkos(),
pendingLimit = 2,
manualLimit = 10,
): Promise<OrganizationOnboardingRecoveryJobResult> {
const result: OrganizationOnboardingRecoveryJobResult = {
...await reconcilePendingOrganizationOnboarding(workos, pendingLimit),
manualScanned: 0,
manualQueued: 0,
};
const manualOperations = await listManualOrganizationOnboardingOperations(manualLimit);
result.manualScanned = manualOperations.length;

for (const operation of manualOperations) {
await createEscalation({
workos_user_id: operation.authenticatedWorkosUserId,
category: 'needs_human_action',
priority: 'high',
summary: `Organization onboarding operation ${operation.id} requires reconciliation`,
addie_context: [
`Operation ${operation.id} is durably fenced in manual_reconciliation.`,
`Provider organization: ${operation.workosOrganizationId ?? 'not durably recorded'}.`,
`Reason: ${operation.lastErrorCode ?? 'provider/local state divergence'}.`,
'Do not replay ownership from email, canonical person, or a linked credential.',
'Compare the exact authenticated WorkOS credential, provider organization and owner membership, and local organization state before resolving.',
].join(' '),
dedup_key: `organization-onboarding-reconciliation:${operation.id}`,
});
result.manualQueued += 1;
}

return result;
}
112 changes: 112 additions & 0 deletions server/src/db/migrations/606_organization_onboarding_operations.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
-- Durable exact-credential first-owner onboarding.
--
-- Provider organization and membership writes cannot share a transaction with
-- PostgreSQL. This ledger makes every provider effect recoverable by a stable
-- external ID/idempotency key and keeps the immutable credential proof that
-- authorized the eventual local commit.

CREATE TABLE organization_onboarding_operations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
authenticated_workos_user_id VARCHAR(255) NOT NULL,
canonical_workos_user_id VARCHAR(255) NOT NULL,
identity_id UUID NOT NULL,
binding_version TEXT NOT NULL,
authorization_epoch BIGINT NOT NULL,
credential_email VARCHAR(255) NOT NULL,
credential_email_verified BOOLEAN NOT NULL,

request_fingerprint CHAR(64) NOT NULL,
client_idempotency_key VARCHAR(255),
organization_name VARCHAR(100) NOT NULL,
is_personal BOOLEAN NOT NULL,
company_type VARCHAR(100),
revenue_tier VARCHAR(100),
marketing_opt_in BOOLEAN,
verified_domain VARCHAR(255),
terms_version VARCHAR(50) NOT NULL,
privacy_version VARCHAR(50) NOT NULL,
consent_ip VARCHAR(50),
consent_user_agent TEXT,

provider_idempotency_key UUID NOT NULL UNIQUE DEFAULT gen_random_uuid(),
provider_external_id VARCHAR(255) NOT NULL UNIQUE,
workos_organization_id VARCHAR(255),
workos_organization_name VARCHAR(255),
workos_membership_id VARCHAR(255),

status VARCHAR(40) NOT NULL DEFAULT 'pending_provider_organization'
CHECK (status IN (
'pending_provider_organization',
'pending_provider_membership',
'pending_local_commit',
'compensating',
'completed',
'failed',
'manual_reconciliation'
)),
lease_token UUID,
lease_expires_at TIMESTAMPTZ,
last_error_code VARCHAR(100),
terminal_outcome JSONB,
completed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),

CHECK ((is_personal AND verified_domain IS NULL)
OR (NOT is_personal AND verified_domain IS NOT NULL)),
CHECK ((status = 'completed' AND workos_organization_id IS NOT NULL
AND workos_membership_id IS NOT NULL AND completed_at IS NOT NULL)
OR status <> 'completed'),
CHECK (status NOT IN ('compensating', 'failed') OR terminal_outcome IS NOT NULL)
);

CREATE UNIQUE INDEX organization_onboarding_one_active_credential
ON organization_onboarding_operations (authenticated_workos_user_id)
WHERE status NOT IN ('completed', 'failed');

-- Linked credentials share org-count and personal-workspace limits. Serializing
-- by identity prevents sibling credentials from passing those checks in
-- parallel while authority remains bound to the exact credential above.
CREATE UNIQUE INDEX organization_onboarding_one_active_identity
ON organization_onboarding_operations (identity_id)
WHERE status NOT IN ('completed', 'failed');

CREATE UNIQUE INDEX organization_onboarding_one_active_domain
ON organization_onboarding_operations (verified_domain)
WHERE verified_domain IS NOT NULL AND status NOT IN ('completed', 'failed');

CREATE UNIQUE INDEX organization_onboarding_client_idempotency
ON organization_onboarding_operations (
authenticated_workos_user_id,
client_idempotency_key
)
WHERE client_idempotency_key IS NOT NULL;

CREATE INDEX organization_onboarding_retry_lookup
ON organization_onboarding_operations (
authenticated_workos_user_id,
request_fingerprint,
created_at DESC
);

CREATE INDEX organization_onboarding_reconciliation_queue
ON organization_onboarding_operations (updated_at, created_at)
WHERE status IN (
'pending_provider_organization',
'pending_provider_membership',
'pending_local_commit',
'compensating'
);

CREATE INDEX organization_onboarding_manual_reconciliation_lookup
ON organization_onboarding_operations (updated_at, created_at)
WHERE status = 'manual_reconciliation';

COMMENT ON TABLE organization_onboarding_operations IS
'Durable exact-credential first-owner operations; provider effects are reconciled by stable WorkOS external ID before local authority commits';
COMMENT ON COLUMN organization_onboarding_operations.binding_version IS
'PostgreSQL xmin captured from the exact identity_workos_users credential binding';
COMMENT ON COLUMN organization_onboarding_operations.provider_external_id IS
'Stable WorkOS organization external ID used to recover ambiguous create responses';
COMMENT ON COLUMN organization_onboarding_operations.terminal_outcome IS
'Validated public failure outcome replayed after durable provider compensation';
48 changes: 8 additions & 40 deletions server/src/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9077,45 +9077,13 @@ ${p.category ? `<category>${p.category}</category>\n` : ''}<url>${publishedUrl}<
}
});

// POST /api/invitations/:invitationId/accept - Accept an invitation
// POST /api/invitations/:invitationId/accept - Contained until acceptance
// is bound to an exact credential and serialized with membership/audit.
this.app.post('/api/invitations/:invitationId/accept', requireAuth, async (req, res) => {
try {
const user = req.user!;
const { invitationId } = req.params;

// Get the invitation to verify it belongs to this user
const invitation = await workos!.userManagement.getInvitation(invitationId);

if (invitation.email.toLowerCase() !== user.email.toLowerCase()) {
return res.status(403).json({
error: 'Access denied',
message: 'This invitation is not for your email address',
});
}

if (invitation.state !== 'pending') {
return res.status(400).json({
error: 'Invalid invitation',
message: 'This invitation has already been accepted or has expired',
});
}

// Accept the invitation - this creates the membership
await workos!.userManagement.acceptInvitation(invitationId);

logger.info({ userId: user.id, invitationId, orgId: invitation.organizationId }, 'User accepted invitation');

res.json({
success: true,
message: 'Invitation accepted successfully',
organization_id: invitation.organizationId,
});
} catch (error) {
logger.error({ err: error }, 'Accept invitation error:');
res.status(500).json({
error: 'Failed to accept invitation',
});
}
return res.status(403).json({
error: 'organization_invitation_acceptance_unavailable',
message: 'Self-service invitation acceptance is temporarily unavailable while membership is bound to your exact credential. Ask an organization owner to complete access, or contact support.',
});
});

// GET /api/me/joinable-organizations - Get organizations the user can request to join
Expand Down Expand Up @@ -9213,8 +9181,8 @@ ${p.category ? `<category>${p.category}</category>\n` : ''}<url>${publishedUrl}<
// proof to the canonical user. Restore through the explicit consent flow.
this.app.post('/api/join-requests', requireAuth, async (_req, res) => {
return res.status(403).json({
error: 'organization_onboarding_disabled',
message: 'Organization join onboarding is temporarily unavailable.',
error: 'organization_join_onboarding_unavailable',
message: 'Self-service join requests are temporarily unavailable. Ask an organization owner to send a WorkOS invitation, or contact support.',
});
});

Expand Down
6 changes: 3 additions & 3 deletions server/src/routes/member-agents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,9 +152,9 @@ export function createMemberAgentsRouter(config: MemberAgentsRouterConfig): Rout
): Promise<{ orgId: string; orgAutoCreated: boolean } | null> {
const apiKey = (req as typeof req & { apiKey?: ValidatedApiKey }).apiKey;
if (!apiKey && (typeof req.query.org !== 'string' || !req.query.org)) {
res.status(403).json({
error: 'organization_onboarding_disabled',
message: 'Select an existing organization explicitly with ?org= to register an agent.',
res.status(400).json({
error: 'explicit_organization_required',
message: 'Create an organization in onboarding first, then retry with its explicit ?org= identifier.',
});
return null;
}
Expand Down
Loading
Loading