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
62 changes: 62 additions & 0 deletions API/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2002,6 +2002,68 @@ components:
type: string

paths:
/org/organisations/{orgId}/teams/{teamId}/archive:
parameters:
- $ref: '#/components/parameters/orgIdPath'
- $ref: '#/components/parameters/teamIdPath'
- $ref: '#/components/parameters/domainQuery'
- $ref: '#/components/parameters/configUrl'
post:
operationId: archiveSharedTeam
tags: [Organizations]
summary: Archive a shared team with permanent deletion held
description: |
Requires a fresh exact-team human credential and live organisation teams.manage capability.
Backend-only callers cannot archive. Stops team access across products, including the
default or last team, without erasing its organisation, memberships, people or data.
Future team billing is reconciled independently; 202 never certifies that billing stopped.
security:
- domainHashBearer: []
accessToken: []
requestBody:
required: true
content:
application/json:
schema:
type: object
additionalProperties: false
required: [request_id, deletion_held]
properties:
request_id: { type: string, format: uuid }
deletion_held: { type: boolean, enum: [true] }
responses:
'202':
description: Durable archive receipt; status is STOPPING_BILLING until provider reconciliation succeeds.
'401':
description: Missing, expired or revoked human or product authority.
'403':
description: Exact selected team or organisation management capability refused.
'409':
description: Idempotency key belongs to a different request or the team is already terminal.
/domain/team-archives/{id}:
parameters:
- $ref: '#/components/parameters/domainQuery'
- name: id
in: path
required: true
schema: { type: string, format: uuid }
get:
operationId: readTeamArchiveReceipt
tags: [Organizations]
summary: Read an archive receipt after the human session ends
description: |
Bound to the initiating registered product. Returns only operation and UOA scope references,
ARCHIVED or STOPPING_BILLING, access_stopped, billing_stopped, deletion_held=true,
permanently_deleted=false and timestamps. It grants no membership, content or mutation access.
security:
- domainHashBearer: []
responses:
'200':
description: Minimal no-store receipt. Only ARCHIVED certifies that future team billing stopped.
'401':
description: Missing or revoked registered product credential.
'404':
description: Operation does not exist or belongs to another product.
/auth/action-verification/start:
post:
operationId: startActionVerification
Expand Down
130 changes: 130 additions & 0 deletions API/prisma/migrations/20261107123000_hold_team_archives/migration.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
SET lock_timeout = '5s';
SET statement_timeout = '120s';

ALTER TYPE "EntityLifecycleStatus" ADD VALUE IF NOT EXISTS 'ARCHIVED';

CREATE TABLE team_archive_operations (
id UUID PRIMARY KEY,
team_id TEXT NOT NULL UNIQUE,
org_id TEXT NOT NULL,
source_client_domain_id TEXT NOT NULL,
actor_user_id TEXT NOT NULL,
deletion_held BOOLEAN NOT NULL DEFAULT true CHECK (deletion_held),
billing_stopped_at TIMESTAMP(3),
settle_after TIMESTAMP(3) NOT NULL,
lease_owner UUID,
lease_expires_at TIMESTAMP(3),
next_attempt_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (team_id, org_id) REFERENCES teams(id, org_id) ON DELETE RESTRICT
);
CREATE INDEX team_archive_operations_billing_stopped_at_next_attempt_at_idx
ON team_archive_operations(billing_stopped_at, next_attempt_at);
CREATE UNIQUE INDEX team_archive_operations_team_id_org_id_key ON team_archive_operations(team_id, org_id);
ALTER TABLE team_archive_operations ENABLE ROW LEVEL SECURITY;
ALTER TABLE team_archive_operations FORCE ROW LEVEL SECURITY;
REVOKE ALL ON team_archive_operations FROM PUBLIC, uoa_app;
GRANT SELECT, INSERT, UPDATE ON team_archive_operations TO uoa_admin;

CREATE FUNCTION hold_archived_team() RETURNS trigger
LANGUAGE plpgsql SECURITY DEFINER SET search_path = pg_catalog, pg_temp AS $$
DECLARE held BOOLEAN;
BEGIN
EXECUTE format('SELECT EXISTS (SELECT 1 FROM %I.team_archive_operations WHERE team_id=$1)', TG_TABLE_SCHEMA)
INTO held USING OLD.id;
IF held AND (TG_OP = 'DELETE' OR NEW.lifecycle_status::text <> 'ARCHIVED') THEN
RAISE EXCEPTION 'team archive deletion is held' USING ERRCODE = '23514';
END IF;
IF TG_OP = 'DELETE' THEN RETURN OLD; END IF;
RETURN NEW;
END;
$$;
CREATE TRIGGER hold_archived_team BEFORE DELETE OR UPDATE OF lifecycle_status ON teams
FOR EACH ROW EXECUTE FUNCTION hold_archived_team();

CREATE FUNCTION preserve_team_archive_hold() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
IF TG_OP = 'DELETE' OR ROW(NEW.id, NEW.team_id, NEW.org_id,
NEW.source_client_domain_id, NEW.actor_user_id, NEW.deletion_held, NEW.created_at)
IS DISTINCT FROM ROW(OLD.id, OLD.team_id, OLD.org_id,
OLD.source_client_domain_id, OLD.actor_user_id, OLD.deletion_held, OLD.created_at)
OR (OLD.billing_stopped_at IS NOT NULL AND NEW.billing_stopped_at IS DISTINCT FROM OLD.billing_stopped_at)
THEN RAISE EXCEPTION 'team archive evidence is immutable' USING ERRCODE = '23514'; END IF;
RETURN NEW;
END;
$$;
CREATE TRIGGER preserve_team_archive_hold BEFORE UPDATE OR DELETE ON team_archive_operations
FOR EACH ROW EXECUTE FUNCTION preserve_team_archive_hold();

-- Lifecycle cancellation has its own exact-team evidence. Retain the original
-- immutable consent revisions and monetary history; clear current consent pointers
-- without forging a customer billing action.
CREATE FUNCTION hold_archived_team_reload() RETURNS trigger
LANGUAGE plpgsql SECURITY DEFINER SET search_path = pg_catalog, pg_temp AS $$
DECLARE held BOOLEAN;
BEGIN
EXECUTE format('SELECT EXISTS (SELECT 1 FROM %I.team_archive_operations WHERE team_id=$1 AND org_id=$2)', TG_TABLE_SCHEMA)
INTO held USING NEW.team_id, NEW.org_id;
IF NEW.auto_top_up_state::text <> 'DISABLED' AND held THEN
RAISE EXCEPTION 'archived team cannot enable reload' USING ERRCODE = '23514';
END IF;
RETURN NEW;
END;
$$;
CREATE TRIGGER hold_archived_team_reload BEFORE INSERT OR UPDATE ON billing_credit_accounts
FOR EACH ROW EXECUTE FUNCTION hold_archived_team_reload();
CREATE OR REPLACE FUNCTION "billing_credit_auto_top_up_generation_guard"() RETURNS trigger
LANGUAGE plpgsql AS $$
DECLARE
snapshot_changed BOOLEAN;
BEGIN
IF TG_OP = 'INSERT' THEN
IF NEW."auto_top_up_generation" <> 0 THEN
RAISE EXCEPTION 'new credit account generation must begin at zero'
USING ERRCODE = '23514';
END IF;
RETURN NEW;
END IF;
snapshot_changed := ROW(
NEW."auto_top_up_policy_id", NEW."auto_top_up_service_id",
NEW."auto_top_up_app_key_id", NEW."auto_top_up_consent_revision_id",
NEW."auto_top_up_option_id", NEW."auto_top_up_threshold_microcredits",
NEW."auto_top_up_refill_offer_id", NEW."auto_top_up_monthly_charge_cap_minor",
NEW."auto_top_up_consent_version", NEW."auto_top_up_consented_at",
NEW."auto_top_up_consented_by_user_id", NEW."stripe_payment_method_id",
NEW."payment_method_summary"
) IS DISTINCT FROM ROW(
OLD."auto_top_up_policy_id", OLD."auto_top_up_service_id",
OLD."auto_top_up_app_key_id", OLD."auto_top_up_consent_revision_id",
OLD."auto_top_up_option_id", OLD."auto_top_up_threshold_microcredits",
OLD."auto_top_up_refill_offer_id", OLD."auto_top_up_monthly_charge_cap_minor",
OLD."auto_top_up_consent_version", OLD."auto_top_up_consented_at",
OLD."auto_top_up_consented_by_user_id", OLD."stripe_payment_method_id",
OLD."payment_method_summary"
);
IF snapshot_changed AND NEW."auto_top_up_generation" <> OLD."auto_top_up_generation" + 1 THEN
RAISE EXCEPTION 'automatic top-up consent change must advance generation once'
USING ERRCODE = '23514';
ELSIF NOT snapshot_changed
AND NEW."auto_top_up_generation" <> OLD."auto_top_up_generation" THEN
RAISE EXCEPTION 'automatic top-up generation changed without a consent change'
USING ERRCODE = '23514';
END IF;
IF OLD."auto_top_up_state" <> 'DISABLED' AND NEW."auto_top_up_state" = 'DISABLED'
AND NOT EXISTS (SELECT 1 FROM team_archive_operations a JOIN teams t ON t.id=a.team_id WHERE a.team_id=OLD.team_id AND a.org_id=OLD.org_id AND t.lifecycle_status::text='ARCHIVED') AND NOT EXISTS (
SELECT 1
FROM "billing_credit_auto_top_up_disable_events" AS disable_event
WHERE disable_event."credit_account_id" = OLD."id"
AND disable_event."account_id" = OLD."account_id"
AND disable_event."org_id" = OLD."org_id"
AND disable_event."team_id" IS NOT DISTINCT FROM OLD."team_id"
AND disable_event."previous_generation" = OLD."auto_top_up_generation"
AND disable_event."previous_consent_revision_id"
IS NOT DISTINCT FROM OLD."auto_top_up_consent_revision_id"
) THEN
RAISE EXCEPTION 'automatic top-up disable requires manager-audited evidence'
USING ERRCODE = '23514';
END IF;
RETURN NEW;
END;
$$;
24 changes: 24 additions & 0 deletions API/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,33 @@ enum UserRole {
enum EntityLifecycleStatus {
ACTIVE
DISABLED
ARCHIVED
DELETING
DELETED
}

// An archive is a durable hold, never an executable erasure job. The authority
// and commercial evidence remain in UOA after every product loses access.
model TeamArchiveOperation {
id String @id @db.Uuid
teamId String @unique @map("team_id")
orgId String @map("org_id")
sourceClientDomainId String @map("source_client_domain_id")
actorUserId String @map("actor_user_id")
deletionHeld Boolean @default(true) @map("deletion_held")
billingStoppedAt DateTime? @map("billing_stopped_at")
settleAfter DateTime @map("settle_after")
leaseOwner String? @map("lease_owner") @db.Uuid
leaseExpiresAt DateTime? @map("lease_expires_at")
nextAttemptAt DateTime @default(now()) @map("next_attempt_at")
createdAt DateTime @default(now()) @map("created_at")
team Team @relation(fields: [teamId, orgId], references: [id, orgId], onDelete: Restrict)

@@unique([teamId, orgId])
@@index([billingStoppedAt, nextAttemptAt])
@@map("team_archive_operations")
}

model LifecycleReasonTemplate {
id String @id @default(cuid())
scope LifecycleScope
Expand Down Expand Up @@ -1568,6 +1591,7 @@ model FeatureFlagUserOverride {
}

model Team {
archiveOperation TeamArchiveOperation?
id String @id @default(cuid())
lifecycleStatus EntityLifecycleStatus @default(ACTIVE) @map("lifecycle_status")
lifecycleChangedAt DateTime? @map("lifecycle_changed_at")
Expand Down
3 changes: 3 additions & 0 deletions API/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import tenantContextPlugin from './plugins/tenant-context.plugin.js';
import { registerRoutes } from './routes/index.js';
import { preloadTariffSnapshotSigningKey } from './services/billing-snapshot.service.js';
import { startStripeBillingScheduler } from './services/billing-stripe-scheduler.service.js';
import { startTeamArchiveReconciliation } from './services/team-archive-reconciliation.service.js';
import { startCreditSettlementScheduler } from './services/billing-credit-settlement-scheduler.service.js';
import { startSeatTransitionScheduler } from './services/billing-seat-transition-scheduler.service.js';
import { startManualCycleReconciliationScheduler } from './services/billing-cycle-manual-reconciliation-scheduler.service.js';
Expand Down Expand Up @@ -302,6 +303,8 @@ export async function createApp(): Promise<FastifyInstance> {
stripeBillingScheduler.stop();
});
}
const teamArchives = startTeamArchiveReconciliation({ log: app.log });
app.addHook('onClose', async () => { teamArchives.stop(); });
const creditSettlementScheduler = startCreditSettlementScheduler({ log: app.log });
app.addHook('onClose', async () => {
creditSettlementScheduler.stop();
Expand Down
2 changes: 2 additions & 0 deletions API/src/routes/domain/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { registerHistoricalUserRoutes } from './historical-users.js';
import type { FastifyInstance } from 'fastify';
import { registerProductDeletionJobs } from './deletion-jobs.js';
import { registerTeamArchiveReceipts } from './team-archives.js';

import { registerDomainDebugRoute } from './debug.js';
import { registerDomainLogsRoute } from './logs.js';
Expand All @@ -13,6 +14,7 @@ import { registerDomainSignatureRoutes } from './signatures.js';
export function registerDomainRoutes(app: FastifyInstance): void {
registerHistoricalUserRoutes(app);
registerProductDeletionJobs(app);
registerTeamArchiveReceipts(app);
registerDomainDebugRoute(app);
registerDomainLogsRoute(app);
registerDomainUsersRoute(app);
Expand Down
16 changes: 16 additions & 0 deletions API/src/routes/domain/team-archives.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { requireDomainHashAuthForDomainQuery } from '../../middleware/domain-hash-auth.js';
import { readTeamArchive } from '../../services/team-archive.service.js';
import { reconcileTeamArchives } from '../../services/team-archive-reconciliation.service.js';
import { AppError } from '../../utils/errors.js';

export function registerTeamArchiveReceipts(app: FastifyInstance) {
app.get('/domain/team-archives/:id', { preHandler: [requireDomainHashAuthForDomainQuery] }, async (request, reply) => {
const { id } = z.object({ id: z.uuid() }).parse(request.params);
if (!request.domainAuthClientDomainId) throw new AppError('UNAUTHORIZED', 401);
const receipt = await readTeamArchive(id, request.domainAuthClientDomainId);
if (!receipt.billing_stopped) await reconcileTeamArchives({ operationId: id });
return reply.header('Cache-Control', 'no-store').send(await readTeamArchive(id, request.domainAuthClientDomainId));
});
}
2 changes: 2 additions & 0 deletions API/src/routes/org/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { registerOrganisationMemberRoutes } from './organisation-members.js';
import { registerGroupRoutes } from './groups.js';
import { registerTeamAvatarRoutes } from './team-avatar.js';
import { registerTeamRoutes } from './teams.js';
import { registerTeamArchiveRoutes } from './team-archives.js';
import { registerTeamSelfJoinRoute } from './team-self-join.js';
import { registerAccessRequestRoutes } from './access-requests.js';
import { registerTeamInvitationRoutes } from './team-invitations.js';
Expand All @@ -19,6 +20,7 @@ export function registerOrgRoutes(app: FastifyInstance): void {
registerOrganisationMemberRoutes(app);
registerGroupRoutes(app);
registerTeamRoutes(app);
registerTeamArchiveRoutes(app);
registerTeamAvatarRoutes(app);
registerTeamSelfJoinRoute(app);
registerTeamInvitationRoutes(app);
Expand Down
24 changes: 24 additions & 0 deletions API/src/routes/org/team-archives.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { configVerifier } from '../../middleware/config-verifier.js';
import { requireDomainHashAuthForDomainQuery } from '../../middleware/domain-hash-auth.js';
import { requireOrgRole } from '../../middleware/org-role-guard.js';
import { requireOrgFeatures } from '../../middleware/org-features.js';
import { beginTeamArchive } from '../../services/team-archive.service.js';
import { AppError } from '../../utils/errors.js';
import { parseDomainContext, parseDomainContextHook, requireVerifiedConfig } from './team-route.shared.js';

export function registerTeamArchiveRoutes(app: FastifyInstance) {
app.post('/org/organisations/:orgId/teams/:teamId/archive', {
preValidation: [requireDomainHashAuthForDomainQuery(), configVerifier,
parseDomainContextHook, requireOrgFeatures, requireOrgRole()],
}, async (request, reply) => {
const { orgId, teamId } = z.object({ orgId: z.string().min(1), teamId: z.string().min(1) }).parse(request.params);
const { request_id } = z.object({ request_id: z.uuid(), deletion_held: z.literal(true) }).strict().parse(request.body);
const { domain } = parseDomainContext(request);
if (!request.accessTokenClaims || !request.domainAuthClientId) throw new AppError('UNAUTHORIZED', 401);
const receipt = await beginTeamArchive({ id: request_id, orgId, teamId, domain,
domainToken: request.domainAuthClientId, claims: request.accessTokenClaims, config: requireVerifiedConfig(request) });
return reply.header('Cache-Control', 'no-store').code(202).send(receipt);
});
}
Loading
Loading