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
19 changes: 19 additions & 0 deletions packages/core/src/db.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1047,6 +1047,15 @@ describe("ensureAdditiveSchemaCompatibility schema-compat gate", () => {
expect(tableExists(db, "share_operation_projects")).toBe(true);
expect(tableExists(db, "share_operation_steps")).toBe(true);
expect(tableExists(db, "recipient_policy_review_resolutions")).toBe(true);
for (const table of [
"policy_teams",
"policy_team_memberships",
"identity_devices",
"project_recipients",
]) {
expect(tableExists(db, table)).toBe(true);
}
expect(tableExists(db, "identities")).toBe(false);
expect(appliedSchemaVersion(db)).toBe(SCHEMA_VERSION);
});

Expand All @@ -1071,6 +1080,16 @@ describe("ensureAdditiveSchemaCompatibility schema-compat gate", () => {
expect(tableExists(previous, table)).toBe(true);
}
expect(tableExists(previous, "recipient_policy_review_resolutions")).toBe(true);
for (const table of [
"policy_teams",
"policy_team_memberships",
"identity_devices",
"project_recipients",
]) {
expect(tableExists(previous, table)).toBe(true);
}
expect(tableExists(previous, "identities")).toBe(false);
expect(getSchemaVersion(previous)).toBe(SCHEMA_VERSION);
expect(columnExists(previous, "share_operations", "pending_person_operation_id")).toBe(true);
expect(columnExists(previous, "share_operations", "recipient_device_id")).toBe(true);
expect(columnExists(previous, "share_operations", "bootstrap_grant_id")).toBe(true);
Expand Down
71 changes: 70 additions & 1 deletion packages/core/src/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ import { canAutoBootstrapSchema, ensureSchemaBootstrapped } from "./schema-boots
export type { DatabaseType as Database };

/** Current schema version this TS runtime was built against. */
export const SCHEMA_VERSION = 11;
export const SCHEMA_VERSION = 12;

/**
* Minimum schema version the TS runtime can operate with.
Expand Down Expand Up @@ -741,6 +741,65 @@ export function ensureAdditiveSchemaCompatibility(db: DatabaseType): void {
resolved_at TEXT NOT NULL,
PRIMARY KEY (review_item_id, source_fingerprint)
);
CREATE TABLE IF NOT EXISTS policy_teams (
team_id TEXT PRIMARY KEY NOT NULL,
display_name TEXT NOT NULL,
status TEXT NOT NULL,
provenance TEXT NOT NULL,
revision TEXT NOT NULL,
migration_state TEXT NOT NULL,
source_fingerprint TEXT,
idempotency_key TEXT NOT NULL UNIQUE,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS policy_team_memberships (
team_id TEXT NOT NULL,
identity_id TEXT NOT NULL,
role TEXT NOT NULL,
status TEXT NOT NULL,
provenance TEXT NOT NULL,
revision TEXT NOT NULL,
migration_state TEXT NOT NULL,
source_fingerprint TEXT,
idempotency_key TEXT NOT NULL UNIQUE,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
PRIMARY KEY (team_id, identity_id)
);
CREATE TABLE IF NOT EXISTS identity_devices (
device_id TEXT PRIMARY KEY NOT NULL,
identity_id TEXT NOT NULL,
display_name TEXT NOT NULL,
status TEXT NOT NULL,
provenance TEXT NOT NULL,
revision TEXT NOT NULL,
migration_state TEXT NOT NULL,
source_fingerprint TEXT,
idempotency_key TEXT NOT NULL UNIQUE,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS project_recipients (
canonical_project_identity TEXT NOT NULL,
recipient_kind TEXT NOT NULL,
recipient_id TEXT NOT NULL,
status TEXT NOT NULL,
provenance TEXT NOT NULL,
policy_revision TEXT NOT NULL,
migration_state TEXT NOT NULL,
source_fingerprint TEXT,
idempotency_key TEXT NOT NULL UNIQUE,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
PRIMARY KEY (canonical_project_identity, recipient_kind, recipient_id)
);
CREATE INDEX IF NOT EXISTS idx_policy_team_memberships_identity_status
ON policy_team_memberships(identity_id, status);
CREATE INDEX IF NOT EXISTS idx_identity_devices_identity_status
ON identity_devices(identity_id, status);
CREATE INDEX IF NOT EXISTS idx_project_recipients_project_status
ON project_recipients(canonical_project_identity, status);
CREATE TABLE IF NOT EXISTS share_operations (
operation_id TEXT PRIMARY KEY NOT NULL,
state TEXT NOT NULL,
Expand Down Expand Up @@ -1301,6 +1360,16 @@ export function ensureAdditiveSchemaCompatibility(db: DatabaseType): void {
}
}

const recipientPolicyTablesReady = [
"policy_teams",
"policy_team_memberships",
"identity_devices",
"project_recipients",
].every((table) => tableExists(db, table));
const currentVersion = getSchemaVersion(db);
if (recipientPolicyTablesReady && currentVersion > 0 && currentVersion < SCHEMA_VERSION) {
db.pragma(`user_version = ${SCHEMA_VERSION}`);
}
markSchemaCompatApplied(db);
}
try {
Expand Down
22 changes: 22 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -588,8 +588,21 @@ export type {
RecipientPolicyTeamV1,
} from "./recipient-policy-contract.js";
export { RECIPIENT_POLICY_CONTRACT_VERSION } from "./recipient-policy-contract.js";
export type { RecipientPolicyIntentGraphV1 } from "./recipient-policy-intent.js";
export { listRecipientPolicyIntent } from "./recipient-policy-intent.js";
export type {
RecipientPolicyMigrationOptions,
RecipientPolicyMigrationProjectResultV1,
RecipientPolicyMigrationProjectStatus,
RecipientPolicyMigrationResultV1,
} from "./recipient-policy-migration.js";
export {
deterministicPolicyTeamId,
migrateRecipientPolicyIntent,
} from "./recipient-policy-migration.js";
export type {
RecipientPolicyActionableReviewItemV1,
RecipientPolicyDerivedReviewState,
RecipientPolicyReviewActionOptionV1,
RecipientPolicyReviewBulkResultV1,
RecipientPolicyReviewContext,
Expand All @@ -599,6 +612,7 @@ export type {
RecipientPolicyReviewResolveStatusV1,
} from "./recipient-policy-review.js";
export {
deriveRecipientPolicyReviewState,
listRecipientPolicyReview,
recipientPolicyReviewSourceFingerprint,
resolveRecipientPolicyReview,
Expand All @@ -614,9 +628,17 @@ export { clearMemoryRefs, normalizeConcept, populateMemoryRefs } from "./ref-pop
export type { RefQueryOptions, RefQueryResult } from "./ref-queries.js";
export { findByConcept, findByFile } from "./ref-queries.js";
export type {
IdentityDevice,
MaintenanceJob,
NewIdentityDevice,
NewMaintenanceJob,
NewPolicyTeam,
NewPolicyTeamMembership,
NewProjectRecipient,
NewRecipientPolicyReviewResolution,
PolicyTeam,
PolicyTeamMembership,
ProjectRecipient,
RecipientPolicyReviewResolution,
} from "./schema.js";
export * as schema from "./schema.js";
Expand Down
14 changes: 14 additions & 0 deletions packages/core/src/recipient-policy-contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
type RecipientPolicyTeamMembershipV1,
type RecipientPolicyTeamV1,
} from "./recipient-policy-contract.js";
import type { RecipientPolicyIntentGraphV1 } from "./recipient-policy-intent.js";

const PERSONAL_IDENTITY = {
version: RECIPIENT_POLICY_CONTRACT_VERSION,
Expand Down Expand Up @@ -162,6 +163,19 @@ describe("recipient policy V1 contract", () => {
"scopeId" | "deviceId" | "trusted" | "projectFilters"
>;
expectTypeOf<ForbiddenIntentKey>().toEqualTypeOf<never>();
type ForbiddenGraphKey = Extract<
keyof RecipientPolicyIntentGraphV1,
| "scopes"
| "groupIds"
| "addresses"
| "keys"
| "fingerprints"
| "epochs"
| "cursors"
| "filters"
| "payloads"
>;
expectTypeOf<ForbiddenGraphKey>().toEqualTypeOf<never>();
});

it("makes keeping current setup an actionable outcome", () => {
Expand Down
127 changes: 127 additions & 0 deletions packages/core/src/recipient-policy-intent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import type { Database } from "./db.js";
import {
RECIPIENT_POLICY_CONTRACT_VERSION,
type RecipientPolicyContractVersion,
type RecipientPolicyIdentityDeviceV1,
type RecipientPolicyIdentityV1,
type RecipientPolicyProjectRecipientV1,
type RecipientPolicyTeamMembershipV1,
type RecipientPolicyTeamV1,
} from "./recipient-policy-contract.js";

export interface RecipientPolicyIntentGraphV1 {
version: RecipientPolicyContractVersion;
identities: RecipientPolicyIdentityV1[];
teams: RecipientPolicyTeamV1[];
teamMemberships: RecipientPolicyTeamMembershipV1[];
identityDevices: RecipientPolicyIdentityDeviceV1[];
projectRecipients: RecipientPolicyProjectRecipientV1[];
}

function identityStatus(value: string): RecipientPolicyIdentityV1["status"] {
return value === "pending" || value === "merged" ? value : "active";
}

export function listRecipientPolicyIntent(db: Database): RecipientPolicyIntentGraphV1 {
const identities = db
.prepare(
`SELECT actor_id, display_name, is_local, status, merged_into_actor_id
FROM actors WHERE status <> 'deactivated'
ORDER BY display_name, actor_id`,
)
.all()
.map((row): RecipientPolicyIdentityV1 => {
const value = row as Record<string, unknown>;
return {
version: RECIPIENT_POLICY_CONTRACT_VERSION,
identityId: String(value.actor_id ?? ""),
displayName: String(value.display_name ?? ""),
kind: "other",
verification: "local",
status: identityStatus(String(value.status ?? "active")),
mergedIntoIdentityId:
typeof value.merged_into_actor_id === "string" && value.merged_into_actor_id
? value.merged_into_actor_id
: null,
};
});
const teams = db
.prepare(
"SELECT team_id, display_name, status FROM policy_teams ORDER BY display_name, team_id",
)
.all()
.map((row): RecipientPolicyTeamV1 => {
const value = row as Record<string, unknown>;
return {
version: RECIPIENT_POLICY_CONTRACT_VERSION,
teamId: String(value.team_id ?? ""),
displayName: String(value.display_name ?? ""),
status: value.status === "archived" ? "archived" : "active",
};
});
const teamMemberships = db
.prepare(
`SELECT team_id, identity_id, role, status FROM policy_team_memberships
ORDER BY team_id, identity_id`,
)
.all()
.map((row): RecipientPolicyTeamMembershipV1 => {
const value = row as Record<string, unknown>;
const status = String(value.status ?? "active");
return {
version: RECIPIENT_POLICY_CONTRACT_VERSION,
teamId: String(value.team_id ?? ""),
identityId: String(value.identity_id ?? ""),
role: value.role === "admin" ? "admin" : "member",
status: status === "pending" || status === "revoked" ? status : "active",
};
});
const identityDevices = db
.prepare(
`SELECT identity_id, device_id, display_name, status FROM identity_devices
ORDER BY identity_id, device_id`,
)
.all()
.map((row): RecipientPolicyIdentityDeviceV1 => {
const value = row as Record<string, unknown>;
return {
version: RECIPIENT_POLICY_CONTRACT_VERSION,
identityId: String(value.identity_id ?? ""),
deviceId: String(value.device_id ?? ""),
displayName: String(value.display_name ?? ""),
status: value.status === "revoked" ? "revoked" : "active",
};
});
const projectRecipients = db
.prepare(
`SELECT canonical_project_identity, recipient_kind, recipient_id, status,
provenance, policy_revision
FROM project_recipients
ORDER BY canonical_project_identity, recipient_kind, recipient_id`,
)
.all()
.map((row): RecipientPolicyProjectRecipientV1 => {
const value = row as Record<string, unknown>;
const base = {
version: RECIPIENT_POLICY_CONTRACT_VERSION,
canonicalProjectIdentity: String(value.canonical_project_identity ?? ""),
intentSource:
value.provenance === "exact_project_invite"
? ("legacy_project_invite" as const)
: ("migration" as const),
policyRevision: String(value.policy_revision ?? ""),
status: value.status === "revoked" ? ("revoked" as const) : ("active" as const),
};
return value.recipient_kind === "team"
? { ...base, recipientKind: "team", teamId: String(value.recipient_id ?? "") }
: { ...base, recipientKind: "identity", identityId: String(value.recipient_id ?? "") };
});
return {
version: RECIPIENT_POLICY_CONTRACT_VERSION,
identities,
teams,
teamMemberships,
identityDevices,
projectRecipients,
};
}
Loading