@portalshq/capability-identity 0.2.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,47 @@
1
+ # @portalshq/capability-identity
2
+
3
+ ## 0.2.1
4
+
5
+ - Implement typed explicit auth-gateway sync with subject matching and clear unsupported-store errors.
6
+ - Refresh existing application profiles without inventing required private identity fields;
7
+ provision central profiles while preserving existing suspension, issuer, email, and preferences.
8
+ - Bundle the canonical proto in the built package, add configurable proto paths and RPC deadlines,
9
+ preserve gRPC status errors, and close connections idempotently.
10
+ - Ship atomic, concurrency-serialized central provisioning and non-destructive legacy upgrades.
11
+ Align preference reads and history conflict keys with the actual central schema.
12
+ - Add real gRPC transport, isolated PostgreSQL, sync/error, migration upgrade/rollback, and
13
+ public-export tests. Enforce 95% runtime-source coverage thresholds.
14
+ - Document deployment, migrations, the full interface, troubleshooting, and custom-store ownership.
15
+ No bears-live-app migration is performed.
16
+
17
+ ## 0.2.0
18
+
19
+ ### Minor Changes
20
+
21
+ - bacf537: Promote identity from stub to usable: verified access-token resolution, PostgreSQL-backed cross-channel interaction history stamped with the transaction clock, and bounded identifiers and reads.
22
+
23
+ ## 0.1.3
24
+
25
+ ### Patch Changes
26
+
27
+ - 749ad63: Standardize public npm publishing and repository metadata for releases through
28
+ GitHub Actions trusted publishing. Remove recursive publish lifecycle hooks.
29
+ - Updated dependencies [749ad63]
30
+ - @portalshq/contracts@0.0.5
31
+
32
+ ## 0.1.2
33
+
34
+ ### Patch Changes
35
+
36
+ - aff6c1d: Standardize public npm publishing and repository metadata for releases through
37
+ GitHub Actions trusted publishing. Remove recursive publish lifecycle hooks.
38
+ - Updated dependencies [aff6c1d]
39
+ - @portalshq/contracts@0.0.4
40
+
41
+ ## 0.1.1
42
+
43
+ ### Patch Changes
44
+
45
+ - 8884dd5: feat: add ESM build infrastructure to all packages
46
+ - Updated dependencies [8884dd5]
47
+ - @portalshq/contracts@0.0.3
package/README.md CHANGED
@@ -1,33 +1,205 @@
1
1
  # @portalshq/capability-identity
2
2
 
3
- **Reusable agent module:** [integration guide](../../../docs/package-agents/identity.md)
3
+ Server-side verified identity and cross-channel history. Requires Node.js 22 and PostgreSQL 14+.
4
4
 
5
- Verified application identity and persistent cross-channel interaction history.
5
+ `IdentityProvider` verifies credentials before identity/history operations. Both PostgreSQL
6
+ adapters require an active local account for those operations. Profile sync and soft-key
7
+ validation are explicit trusted-server operations: they do **not** verify access tokens or
8
+ establish authorization. Never expose sync with an arbitrary browser-supplied user ID.
6
9
 
7
- ## Exported shape
10
+ ## Setup and deployment
8
11
 
9
- - `AudienceIdentity`: `userId` and `displayName`.
10
- - `ChannelInteraction`: soft `channelId` and `sessionId` cross-database keys.
11
- - `ChannelHistoryEntry`: a `ChannelInteraction` plus the stored interaction time.
12
- - `IdentityProvider`: `getIdentity()`, `recordInteraction()`, `getHistory()` — each verifies an access token first.
13
- - `IdentityStore`: persistence contract, so applications can substitute their own storage.
14
- - `PostgresIdentityStore`: adapter for Portals `application_users` and `application_user_channel_history`.
15
- - `QueryRunner`: the minimal `pg.Pool` surface the adapter needs.
12
+ Install this package and `pg` in a server application. Choose one adapter:
16
13
 
17
- ## Setup
14
+ | Adapter | Ownership | Profile sync |
15
+ | --- | --- | --- |
16
+ | `ApplicationIdentityStore` | Existing application-owned accounts | Updates existing profiles; refuses missing accounts |
17
+ | `CentralIdentityStore` | Dedicated shared identity database | Creates missing profiles; updates existing profiles |
18
+ | Custom `IdentityStore` | Application-defined persistence | Optional `SyncableIdentityStore` capability |
18
19
 
19
- Portals operators apply `frontend/migrations/015_application_user_channel_history.sql`; creator
20
- applications do not own or run this migration. Construct the provider with a token verifier that
21
- validates the credential server-side and returns an active `application_users.id`, plus a
22
- `PostgresIdentityStore`. Never let a browser-supplied user ID be the verifier's result.
20
+ Auth-gateway supplies only `userId` and `displayName`. Sync never changes an existing
21
+ account's status, email, encrypted identity, preferences, or issuer. Application account
22
+ creation must go through the application's provisioning flow: its schema requires
23
+ `email_hash` and `identity_ciphertext`, which auth-gateway does not provide.
23
24
 
24
- ## Limits
25
+ Configure `AUTH_GATEWAY_ADDRESS` (`host:port`), `AUTH_GATEWAY_RESOURCE_ID`,
26
+ `AUTH_GATEWAY_TLS=true` for TLS, and `CENTRAL_DATABASE_URL` or `DATABASE_URL`.
27
+ Environment variables are read by the application, not implicitly by this package.
28
+ Use database TLS and least-privilege runtime credentials; run migrations separately with
29
+ DDL credentials. Stop accepting requests before closing the gRPC client and database pool.
25
30
 
26
- - Interaction time is the storage transaction clock, so a caller cannot backdate or forward-date history.
27
- - `channelId` and `sessionId` are 1–128 characters, enforced in code and by the table.
28
- - `getHistory` reads at most `DEFAULT_HISTORY_ENTRIES` (100) rows and clamps any request to `MAX_HISTORY_ENTRIES` (500).
31
+ ```typescript
32
+ import pg from "pg";
33
+ import {
34
+ CentralIdentityStore, IdentityProvider, createAuthGatewayClient,
35
+ } from "@portalshq/capability-identity";
29
36
 
30
- ## Tests
37
+ const pool = new pg.Pool({ connectionString: process.env.CENTRAL_DATABASE_URL });
38
+ const gateway = createAuthGatewayClient({
39
+ address: process.env.AUTH_GATEWAY_ADDRESS!,
40
+ useTls: process.env.AUTH_GATEWAY_TLS === "true",
41
+ timeout: 5000,
42
+ });
43
+ const provider = new IdentityProvider(
44
+ async (token) => (await gateway.verifyUser(token)).userId,
45
+ new CentralIdentityStore(pool),
46
+ );
47
+ // token comes from the authenticated request; provisioning/sync is a separate trusted flow.
48
+ // const identity = await provider.getIdentity(token);
49
+ // await provider.recordInteraction(token, { channelId: "channel-1", sessionId: "session-1" });
50
+ // const history = await provider.getHistory(token, 25);
51
+ ```
31
52
 
32
- `npm test` runs the provider and store unit tests. The PostgreSQL integration suite runs only when
33
- `IDENTITY_TEST_DATABASE_URL` points at a scratch database, because it creates and drops its own tables.
53
+ See the [integration guide](docs/integration-guide.md) for provisioning, profile sync,
54
+ custom-store migration, and a complete runnable example.
55
+
56
+ ## Database migration
57
+
58
+ ### Central database
59
+
60
+ Run only on a dedicated central identity database, before deploying application code:
61
+
62
+ ```typescript
63
+ import pg from "pg";
64
+ import { runCentralMigrations } from "@portalshq/capability-identity";
65
+ const pool = new pg.Pool({ connectionString: process.env.CENTRAL_DATABASE_URL });
66
+ try { await runCentralMigrations(pool); } finally { await pool.end(); }
67
+ ```
68
+
69
+ The versioned `sql/001_central_identity.sql` ships in the npm package. The runner applies
70
+ it atomically with a transaction-scoped advisory lock and can be rerun or called concurrently.
71
+ It creates `users`, `user_preferences` (JSONB values by key), `channel_apps`,
72
+ `channel_schedules`, `channel_sessions`, and `user_channel_history` with history indexes.
73
+ It does not open or close the supplied pool. PostgreSQL errors propagate to the caller.
74
+ Manual alternative: execute that SQL in a transaction with `psql -v ON_ERROR_STOP=1 --single-transaction`.
75
+
76
+ The migration also upgrades the previously distributed central schema: adds active/suspended
77
+ status, allows missing issuer information and optional legacy `app_id` on history, and adds
78
+ the conflict key used by the store. Channel and session IDs must be globally scoped across
79
+ applications; history is unique on `(user_id, channel_id, session_id)`. Legacy `app_id`
80
+ values and metadata are retained. The store does not populate the channel registries.
81
+
82
+ Back up before upgrading. Existing duplicate soft keys, null sessions, or blank/oversized
83
+ identifiers cause migration failure and full rollback. Reconcile these records explicitly
84
+ according to your application's identity rules, then rerun. The migration never deletes
85
+ history to resolve ambiguity. There is no destructive automatic down migration.
86
+
87
+ ### Application database
88
+
89
+ Portals operators provision application accounts and apply
90
+ `frontend/migrations/015_application_user_channel_history.sql` in the platform repository.
91
+ Creator applications do not run platform migrations. `PostgresIdentityStore` remains an
92
+ alias of `ApplicationIdentityStore`. Application sync requires the schema's `updated_at`
93
+ column and never synthesizes the private identity fields required for account creation.
94
+
95
+ ## Public interface
96
+
97
+ ```typescript
98
+ interface AudienceIdentity {
99
+ userId: string;
100
+ displayName: string;
101
+ email?: string;
102
+ preferences?: Record<string, unknown>;
103
+ }
104
+ interface ChannelInteraction { channelId: string; sessionId: string }
105
+ interface ChannelHistoryEntry extends ChannelInteraction {
106
+ lastInteractionAt: string;
107
+ metadata?: Record<string, unknown>;
108
+ }
109
+ interface IdentityStore {
110
+ getIdentity(userId: string): Promise<AudienceIdentity | undefined>;
111
+ recordInteraction(userId: string, interaction: ChannelInteraction): Promise<void>;
112
+ getHistory(userId: string, limit: number): Promise<readonly ChannelHistoryEntry[]>;
113
+ }
114
+ interface SyncableIdentityStore extends IdentityStore {
115
+ syncFromAuthGateway(userId: string, displayName: string): Promise<void>;
116
+ }
117
+ ```
118
+
119
+ - `new IdentityProvider(verifyAccessToken, store)`: verifier is `(token: string) => Promise<string>`;
120
+ it must validate credentials server-side and return the verified subject ID.
121
+ - `provider.getIdentity(accessToken): Promise<AudienceIdentity>`: rejects unknown/suspended accounts.
122
+ - `provider.recordInteraction(accessToken, interaction): Promise<void>`: validates soft IDs,
123
+ then verifies the account and writes using the database clock.
124
+ - `provider.getHistory(accessToken, limit = 100): Promise<readonly ChannelHistoryEntry[]>`:
125
+ requires a positive integer and clamps to 500. Results are newest first.
126
+ - `provider.syncFromAuthGateway(userId, gateway, resourceId): Promise<void>`: requires a
127
+ `getUserInfo` client and a sync-capable store, rejects mismatched subjects and propagates
128
+ upstream/storage errors. IDs must be nonblank and at most 128 trimmed characters.
129
+ - `provider.validateSoftKey(table, key, centralPool): Promise<boolean>`: parameterized lookup
130
+ for `users`, `channel_apps`, or `channel_schedules` only. Invalid table names throw;
131
+ missing rows/database errors return false. Existence does not imply an active account.
132
+ - `new ApplicationIdentityStore(pool)` / `new CentralIdentityStore(pool)`: implement the
133
+ sync-capable interface. Callers using adapters directly must validate IDs and history limits;
134
+ `IdentityProvider` supplies that validation. Neither adapter owns the pool lifecycle.
135
+ - `centralStore.updateUserInfo(userId, displayName): Promise<void>`: legacy update-only
136
+ method, silently leaves a missing user untouched. Prefer explicit sync for provisioning.
137
+ - `runCentralMigrations(pool): Promise<void>`: central provisioning described above.
138
+ - `QueryRunner`: `query(text: string, values?: unknown[])` returning a promise of
139
+ `{ rowCount: number | null; rows: Record<string, unknown>[] }`; compatible with `pg.Pool`.
140
+ - `createAuthGatewayClient({ address, useTls?, timeout?, protoPath? }): AuthGatewayClient`:
141
+ requires a nonblank address; timeout is a finite positive per-call deadline, default 5000ms.
142
+ Uses the bundled proto unless overridden. Default transport is insecure for private/local
143
+ use; enable TLS for deployment. Proto load errors are synchronous.
144
+ - Client methods: `verifyUser(token)`, `getUserInfo(resourceId, userId)`, and
145
+ `getUserId(resourceId, displayName)` return `Promise<UserInfo>`; `UserInfo` contains
146
+ `userId` and `displayName`. Malformed/missing identities reject; gRPC errors retain their
147
+ `code` and `details`. `close(): void` is idempotent; later calls reject.
148
+ - Constants: `MAX_IDENTIFIER_LENGTH=128`, `DEFAULT_HISTORY_ENTRIES=100`,
149
+ `MAX_HISTORY_ENTRIES=500`, and `INACTIVE_ACCOUNT_ERROR`.
150
+
151
+ Interactions upsert without moving timestamps backwards. Account eligibility and writes
152
+ are checked in one statement. Concurrent changes follow PostgreSQL statement snapshot
153
+ semantics; credential revocation and suspension are not a distributed transaction.
154
+ The PostgreSQL adapters return only channel ID, session ID, and timestamp in history;
155
+ optional metadata is reserved for custom adapters. Central preferences come from
156
+ `user_preferences`; application preferences/email remain application-owned.
157
+
158
+ ## Testing and packaging
159
+
160
+ From the repository root:
161
+
162
+ ```sh
163
+ npm run build --workspace=@portalshq/capability-identity
164
+ npm run typecheck --workspace=@portalshq/capability-identity
165
+ npm test --workspace=@portalshq/capability-identity
166
+ npm run test:coverage --workspace=@portalshq/capability-identity
167
+ ```
168
+
169
+ Coverage enforces at least 95% statements, branches, functions, and lines for all published
170
+ TypeScript source. Build tooling is validated separately by building and inspecting the pack.
171
+ The real local gRPC transport suite needs permission to bind localhost. Build copies the
172
+ repository's canonical proto to `src/proto` and `dist/proto`; the latter ships with the package.
173
+ Clean checkouts without the lore submodule build from the checked-in protocol snapshot.
174
+ The source snapshot also lets tests run before building. Updating the canonical proto
175
+ requires a build and committing the refreshed source copy. Installed consumers need neither
176
+ the repository nor the canonical proto path.
177
+
178
+ Database suites use explicit non-production connection variables:
179
+ `IDENTITY_TEST_DATABASE_URL` and `IDENTITY_CENTRAL_TEST_DATABASE_URL`.
180
+ The application suite drops/recreates its named tables and **must use a disposable database**.
181
+ The central suite uses randomly named schemas and removes only those schemas.
182
+ Without URLs, database suites skip. The deployed gateway suite additionally requires
183
+ `AUTH_GATEWAY_ADDRESS`, `AUTH_GATEWAY_RESOURCE_ID`, and
184
+ `IDENTITY_AUTH_GATEWAY_TEST_TOKEN`; supply a dedicated test identity and TLS setting.
185
+ It verifies the token, both lookups, and invalid-credential rejection without writing profiles.
186
+
187
+ ## Troubleshooting
188
+
189
+ | Symptom | Resolution |
190
+ | --- | --- |
191
+ | Inactive account error | Verify subject-to-local-account mapping and local status; sync does not reactivate accounts. |
192
+ | Application account must be provisioned | Run the application's account-creation flow before syncing its profile. |
193
+ | Store does not support sync | Implement `SyncableIdentityStore`, or omit explicit sync for a custom cache. |
194
+ | Central missing columns/conflict constraint | Apply the packaged central migration using DDL credentials before deploying. |
195
+ | Migration fails on duplicate/null/invalid history | Back up and reconcile legacy records; failures roll back, preserving evidence. |
196
+ | Proto not found | Rebuild from the repository or reinstall the published package; check custom `protoPath`. |
197
+ | `UNAVAILABLE` / `DEADLINE_EXCEEDED` | Check address, reachability, TLS, and per-call timeout. Retry only under your caller's policy. |
198
+ | `UNAUTHENTICATED` | Refresh/reject the credential; never fall back to a browser-supplied user ID. |
199
+ | Soft-key lookup returns false | Check registry data and connectivity; database failures deliberately fail closed. |
200
+
201
+ ## Release validation
202
+
203
+ Version 0.2.1 adds profile synchronization, packaged proto assets, central schema provisioning,
204
+ and the tests and guides above. Local PostgreSQL and gRPC transport tests are separate from
205
+ deployed auth-gateway acceptance. See `CHANGELOG.md` for the release record.
@@ -0,0 +1,19 @@
1
+ export interface UserInfo {
2
+ userId: string;
3
+ displayName: string;
4
+ }
5
+ export interface AuthGatewayClient {
6
+ verifyUser(token: string): Promise<UserInfo>;
7
+ getUserInfo(resourceId: string, userId: string): Promise<UserInfo>;
8
+ getUserId(resourceId: string, displayName: string): Promise<UserInfo>;
9
+ close(): void;
10
+ }
11
+ export interface AuthGatewayClientConfig {
12
+ address: string;
13
+ /** Enable TLS outside a trusted private network. */
14
+ useTls?: boolean;
15
+ /** Per-call deadline in milliseconds, defaults to 5000. */
16
+ timeout?: number;
17
+ protoPath?: string;
18
+ }
19
+ export declare function createAuthGatewayClient(config: AuthGatewayClientConfig): AuthGatewayClient;
@@ -0,0 +1,41 @@
1
+ import * as grpc from "@grpc/grpc-js";
2
+ import * as protoLoader from "@grpc/proto-loader";
3
+ import { fileURLToPath } from "node:url";
4
+ export function createAuthGatewayClient(config) {
5
+ if (!config.address.trim())
6
+ throw new TypeError("Auth-gateway address is required.");
7
+ const timeout = config.timeout ?? 5000;
8
+ if (!Number.isFinite(timeout) || timeout <= 0)
9
+ throw new TypeError("Timeout must be positive and finite.");
10
+ const definition = protoLoader.loadSync(config.protoPath ?? fileURLToPath(new URL("./proto/auth_api.proto", import.meta.url)), {
11
+ keepCase: true, longs: String, enums: String, defaults: true, oneofs: true,
12
+ });
13
+ const methods = definition["epic_urc.UrcAuthApi"];
14
+ const descriptor = grpc.loadPackageDefinition(definition);
15
+ const namespace = descriptor.epic_urc;
16
+ const Constructor = namespace.UrcAuthApi;
17
+ const client = new Constructor(config.address, config.useTls ? grpc.credentials.createSsl() : grpc.credentials.createInsecure());
18
+ let closed = false;
19
+ async function call(method, request, expectedId) {
20
+ if (closed)
21
+ throw new Error("Auth-gateway client is closed.");
22
+ const response = await new Promise((resolve, reject) => {
23
+ client.makeUnaryRequest(`/epic_urc.UrcAuthApi/${method}`, (value) => methods[method].requestSerialize(value), (value) => methods[method].responseDeserialize(value), request, new grpc.Metadata(), { deadline: Date.now() + timeout }, (error, result) => error ? reject(error) : resolve(result ?? {}));
24
+ });
25
+ const candidates = Array.isArray(response.user_info) ? response.user_info : response.user_info ? [response.user_info] : [];
26
+ const user = expectedId ? candidates.find((candidate) => candidate.user_id === expectedId) : candidates[0];
27
+ if (!user || typeof user.user_id !== "string" || !user.user_id.trim() || typeof user.display_name !== "string") {
28
+ throw new Error(`Auth-gateway ${method} returned no valid user info`);
29
+ }
30
+ return { userId: user.user_id, displayName: user.display_name };
31
+ }
32
+ return {
33
+ verifyUser: (token) => call("VerifyUser", { target_user: { user_token: token }, requirements: [] }),
34
+ getUserInfo: (resourceId, userId) => call("GetUserInfo", { resource_id: resourceId, user_id: [userId] }, userId),
35
+ getUserId: (resourceId, displayName) => call("GetUserId", { resource_id: resourceId, user_display_name: displayName }),
36
+ close: () => { if (!closed) {
37
+ closed = true;
38
+ client.close();
39
+ } },
40
+ };
41
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Central database identity store.
3
+ *
4
+ * This store is for central Portals identity data (users table in central database).
5
+ * For app-specific user data, use ApplicationIdentityStore.
6
+ *
7
+ * The central database contains cross-app identity data shared across all Portals applications.
8
+ */
9
+ import { type AudienceIdentity, type ChannelHistoryEntry, type ChannelInteraction, type SyncableIdentityStore } from "./identity-provider.js";
10
+ import type { QueryRunner } from "./postgres-store.js";
11
+ /**
12
+ * Central database identity store.
13
+ *
14
+ * This store queries the central database users table for cross-app identity.
15
+ * The central database schema is defined in central-schema.sql.
16
+ */
17
+ export declare class CentralIdentityStore implements SyncableIdentityStore {
18
+ private readonly pool;
19
+ constructor(pool: QueryRunner);
20
+ getIdentity(userId: string): Promise<AudienceIdentity | undefined>;
21
+ /**
22
+ * Record interaction in central database channel history.
23
+ *
24
+ * The active-account check and write share one PostgreSQL statement snapshot.
25
+ * GREATEST keeps the newest interaction when
26
+ * concurrent transactions commit out of order.
27
+ */
28
+ recordInteraction(userId: string, interaction: ChannelInteraction): Promise<void>;
29
+ getHistory(userId: string, limit: number): Promise<readonly ChannelHistoryEntry[]>;
30
+ /**
31
+ * Update user information in central database.
32
+ *
33
+ * This is used by syncFromAuthGateway to update user data from auth-gateway.
34
+ * Note: Auth-gateway only provides user_id and display_name. Email and
35
+ * preferences must be managed by the application.
36
+ */
37
+ syncFromAuthGateway(userId: string, displayName: string): Promise<void>;
38
+ updateUserInfo(userId: string, displayName: string): Promise<void>;
39
+ }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Central database identity store.
3
+ *
4
+ * This store is for central Portals identity data (users table in central database).
5
+ * For app-specific user data, use ApplicationIdentityStore.
6
+ *
7
+ * The central database contains cross-app identity data shared across all Portals applications.
8
+ */
9
+ import { INACTIVE_ACCOUNT_ERROR, } from "./identity-provider.js";
10
+ /**
11
+ * Central database identity store.
12
+ *
13
+ * This store queries the central database users table for cross-app identity.
14
+ * The central database schema is defined in central-schema.sql.
15
+ */
16
+ export class CentralIdentityStore {
17
+ pool;
18
+ constructor(pool) {
19
+ this.pool = pool;
20
+ }
21
+ async getIdentity(userId) {
22
+ const result = await this.pool.query(`SELECT u.id, u.display_name, u.email,
23
+ (SELECT jsonb_object_agg(key, value) FROM user_preferences WHERE user_id = u.id) AS preferences
24
+ FROM users u WHERE id = $1 AND status = 'active'`, [userId]);
25
+ const row = result.rows[0];
26
+ if (!row)
27
+ return undefined;
28
+ return {
29
+ userId: String(row.id),
30
+ displayName: String(row.display_name),
31
+ email: row.email ? String(row.email) : undefined,
32
+ preferences: row.preferences ? row.preferences : undefined,
33
+ };
34
+ }
35
+ /**
36
+ * Record interaction in central database channel history.
37
+ *
38
+ * The active-account check and write share one PostgreSQL statement snapshot.
39
+ * GREATEST keeps the newest interaction when
40
+ * concurrent transactions commit out of order.
41
+ */
42
+ async recordInteraction(userId, interaction) {
43
+ const result = await this.pool.query(`INSERT INTO user_channel_history (user_id, channel_id, session_id, last_interaction_at)
44
+ SELECT $1, $2, $3, now()
45
+ WHERE EXISTS (SELECT 1 FROM users WHERE id = $1 AND status = 'active')
46
+ ON CONFLICT (user_id, channel_id, session_id) DO UPDATE
47
+ SET last_interaction_at = GREATEST(user_channel_history.last_interaction_at, EXCLUDED.last_interaction_at)`, [userId, interaction.channelId, interaction.sessionId]);
48
+ if (!result.rowCount)
49
+ throw new Error(INACTIVE_ACCOUNT_ERROR);
50
+ }
51
+ async getHistory(userId, limit) {
52
+ const result = await this.pool.query(`SELECT channel_id, session_id, last_interaction_at
53
+ FROM user_channel_history
54
+ WHERE user_id = $1 ORDER BY last_interaction_at DESC LIMIT $2`, [userId, limit]);
55
+ return result.rows.map(historyFromRow);
56
+ }
57
+ /**
58
+ * Update user information in central database.
59
+ *
60
+ * This is used by syncFromAuthGateway to update user data from auth-gateway.
61
+ * Note: Auth-gateway only provides user_id and display_name. Email and
62
+ * preferences must be managed by the application.
63
+ */
64
+ async syncFromAuthGateway(userId, displayName) {
65
+ await this.pool.query(`INSERT INTO users (id, display_name) VALUES ($1, $2)
66
+ ON CONFLICT (id) DO UPDATE SET display_name = EXCLUDED.display_name, updated_at = now()`, [userId, displayName]);
67
+ }
68
+ async updateUserInfo(userId, displayName) {
69
+ await this.pool.query("UPDATE users SET display_name = $2, updated_at = now() WHERE id = $1", [userId, displayName]);
70
+ }
71
+ }
72
+ function historyFromRow(row) {
73
+ return {
74
+ channelId: String(row.channel_id),
75
+ sessionId: String(row.session_id),
76
+ lastInteractionAt: new Date(row.last_interaction_at).toISOString(),
77
+ };
78
+ }
@@ -1,3 +1,5 @@
1
+ import type { AuthGatewayClient } from "./auth-gateway-client.js";
2
+ import type { QueryRunner } from "./postgres-store.js";
1
3
  /**
2
4
  * One identity per audience member, scoped to read across every channel
3
5
  * they've ever touched. This is the "Netflix history, but spanning every
@@ -8,6 +10,8 @@
8
10
  export interface AudienceIdentity {
9
11
  userId: string;
10
12
  displayName: string;
13
+ email?: string;
14
+ preferences?: Record<string, unknown>;
11
15
  }
12
16
  /** Soft cross-database key; the matching channel row may live elsewhere. */
13
17
  export interface ChannelInteraction {
@@ -16,6 +20,7 @@ export interface ChannelInteraction {
16
20
  }
17
21
  export interface ChannelHistoryEntry extends ChannelInteraction {
18
22
  lastInteractionAt: string;
23
+ metadata?: Record<string, unknown>;
19
24
  }
20
25
  /** Matches the identifier bounds in the platform migration. */
21
26
  export declare const MAX_IDENTIFIER_LENGTH = 128;
@@ -27,7 +32,10 @@ export interface IdentityStore {
27
32
  recordInteraction(userId: string, interaction: ChannelInteraction): Promise<void>;
28
33
  getHistory(userId: string, limit: number): Promise<readonly ChannelHistoryEntry[]>;
29
34
  }
30
- /** Must validate the credential and return an active application user ID. */
35
+ export interface SyncableIdentityStore extends IdentityStore {
36
+ syncFromAuthGateway(userId: string, displayName: string): Promise<void>;
37
+ }
38
+ /** Must validate the credential and return its verified subject ID. */
31
39
  export type VerifyAccessToken = (accessToken: string) => Promise<string>;
32
40
  export declare class IdentityProvider {
33
41
  private readonly verifyAccessToken;
@@ -37,4 +45,32 @@ export declare class IdentityProvider {
37
45
  /** Interaction time is the storage transaction's clock, never the caller's. */
38
46
  recordInteraction(accessToken: string, interaction: ChannelInteraction): Promise<void>;
39
47
  getHistory(accessToken: string, limit?: number): Promise<readonly ChannelHistoryEntry[]>;
48
+ /**
49
+ * On-demand sync from auth-gateway.
50
+ *
51
+ * Explicitly call this to sync user data from auth-gateway to the local store.
52
+ * This is not called automatically on every request to avoid unnecessary network calls.
53
+ *
54
+ * Note: Auth-gateway UserInfo only contains user_id and display_name.
55
+ * Email and preferences are not available from auth-gateway and must be managed
56
+ * by the application itself.
57
+ *
58
+ * @param userId - The user ID to sync
59
+ * @param authGatewayClient - Auth-gateway gRPC client
60
+ * @param resourceId - The resource ID (application identifier)
61
+ */
62
+ syncFromAuthGateway(userId: string, authGatewayClient: Pick<AuthGatewayClient, "getUserInfo">, resourceId: string): Promise<void>;
63
+ /**
64
+ * Validate a soft key reference to a central database table.
65
+ *
66
+ * Used to validate cross-database references when databases are separate instances.
67
+ * This is an application-level validation because PostgreSQL foreign keys don't
68
+ * work across database instances.
69
+ *
70
+ * @param table - The table name to validate against (e.g., "users")
71
+ * @param key - The key value to validate (e.g., user ID)
72
+ * @param centralPool - PostgreSQL pool for central database connection
73
+ * @returns true if the key exists in the central database, false otherwise
74
+ */
75
+ validateSoftKey(table: string, key: string, centralPool: QueryRunner): Promise<boolean>;
40
76
  }
@@ -28,6 +28,59 @@ export class IdentityProvider {
28
28
  const identity = await this.getIdentity(accessToken);
29
29
  return this.store.getHistory(identity.userId, historyLimit(limit));
30
30
  }
31
+ /**
32
+ * On-demand sync from auth-gateway.
33
+ *
34
+ * Explicitly call this to sync user data from auth-gateway to the local store.
35
+ * This is not called automatically on every request to avoid unnecessary network calls.
36
+ *
37
+ * Note: Auth-gateway UserInfo only contains user_id and display_name.
38
+ * Email and preferences are not available from auth-gateway and must be managed
39
+ * by the application itself.
40
+ *
41
+ * @param userId - The user ID to sync
42
+ * @param authGatewayClient - Auth-gateway gRPC client
43
+ * @param resourceId - The resource ID (application identifier)
44
+ */
45
+ async syncFromAuthGateway(userId, authGatewayClient, resourceId) {
46
+ assertIdentifier(userId, "userId");
47
+ assertIdentifier(resourceId, "resourceId");
48
+ if (!("syncFromAuthGateway" in this.store) || typeof this.store.syncFromAuthGateway !== "function") {
49
+ throw new Error("Identity store does not support auth-gateway sync.");
50
+ }
51
+ const userInfo = await authGatewayClient.getUserInfo(resourceId, userId);
52
+ if (userInfo.userId !== userId || typeof userInfo.displayName !== "string") {
53
+ throw new Error("Auth-gateway returned an invalid or mismatched identity.");
54
+ }
55
+ await this.store.syncFromAuthGateway(userId, userInfo.displayName);
56
+ }
57
+ /**
58
+ * Validate a soft key reference to a central database table.
59
+ *
60
+ * Used to validate cross-database references when databases are separate instances.
61
+ * This is an application-level validation because PostgreSQL foreign keys don't
62
+ * work across database instances.
63
+ *
64
+ * @param table - The table name to validate against (e.g., "users")
65
+ * @param key - The key value to validate (e.g., user ID)
66
+ * @param centralPool - PostgreSQL pool for central database connection
67
+ * @returns true if the key exists in the central database, false otherwise
68
+ */
69
+ async validateSoftKey(table, key, centralPool) {
70
+ // Validate table name to prevent SQL injection
71
+ const allowedTables = ["users", "channel_apps", "channel_schedules"];
72
+ if (!allowedTables.includes(table)) {
73
+ throw new Error(`Invalid table name for soft key validation: ${table}`);
74
+ }
75
+ try {
76
+ const result = await centralPool.query(`SELECT 1 FROM ${table} WHERE id = $1 LIMIT 1`, [key]);
77
+ return result.rowCount !== null && result.rowCount > 0;
78
+ }
79
+ catch (error) {
80
+ console.error(`[identity] Failed to validate soft key in table ${table}:`, error);
81
+ return false;
82
+ }
83
+ }
31
84
  }
32
85
  function assertIdentifier(value, field) {
33
86
  const trimmed = value?.trim() ?? "";
package/dist/index.d.ts CHANGED
@@ -1,2 +1,5 @@
1
1
  export * from "./identity-provider.js";
2
2
  export * from "./postgres-store.js";
3
+ export { CentralIdentityStore } from "./central-store.js";
4
+ export * from "./auth-gateway-client.js";
5
+ export { runCentralMigrations } from "./migrations.js";
package/dist/index.js CHANGED
@@ -1,2 +1,5 @@
1
1
  export * from "./identity-provider.js";
2
2
  export * from "./postgres-store.js";
3
+ export { CentralIdentityStore } from "./central-store.js";
4
+ export * from "./auth-gateway-client.js";
5
+ export { runCentralMigrations } from "./migrations.js";
@@ -0,0 +1,6 @@
1
+ import type { QueryRunner } from "./postgres-store.js";
2
+ /** Apply on a dedicated central database. A single query is one atomic transaction.
3
+ * Safe to rerun; an advisory transaction lock serializes concurrent provisioning.
4
+ * Never run on an application database.
5
+ */
6
+ export declare function runCentralMigrations(pool: QueryRunner): Promise<void>;
@@ -0,0 +1,12 @@
1
+ import { readFile } from "node:fs/promises";
2
+ /** Apply on a dedicated central database. A single query is one atomic transaction.
3
+ * Safe to rerun; an advisory transaction lock serializes concurrent provisioning.
4
+ * Never run on an application database.
5
+ */
6
+ export async function runCentralMigrations(pool) {
7
+ const sql = await readFile(new URL("../sql/001_central_identity.sql", import.meta.url), "utf8");
8
+ await pool.query(`DO $identity_migration$ BEGIN
9
+ PERFORM pg_advisory_xact_lock(761830214);
10
+ ${sql}
11
+ END $identity_migration$;`);
12
+ }
@@ -1,4 +1,4 @@
1
- import { type AudienceIdentity, type ChannelHistoryEntry, type ChannelInteraction, type IdentityStore } from "./identity-provider.js";
1
+ import { type AudienceIdentity, type ChannelHistoryEntry, type ChannelInteraction, type SyncableIdentityStore } from "./identity-provider.js";
2
2
  /** Minimal shape of `pg.Pool`, so the published types need no pg typings. */
3
3
  export interface QueryRunner {
4
4
  query(text: string, values?: unknown[]): Promise<{
@@ -6,15 +6,28 @@ export interface QueryRunner {
6
6
  rows: Array<Record<string, unknown>>;
7
7
  }>;
8
8
  }
9
- export declare class PostgresIdentityStore implements IdentityStore {
9
+ /**
10
+ * Application-specific identity store.
11
+ *
12
+ * This store is for app-specific user data (application_users table).
13
+ * For central database user data, use CentralIdentityStore.
14
+ */
15
+ export declare class ApplicationIdentityStore implements SyncableIdentityStore {
10
16
  private readonly pool;
11
17
  constructor(pool: QueryRunner);
18
+ /** Profile refresh only: account creation requires application-owned identity fields. */
19
+ syncFromAuthGateway(userId: string, displayName: string): Promise<void>;
12
20
  getIdentity(userId: string): Promise<AudienceIdentity | undefined>;
13
21
  /**
14
- * The active-account check and the write are one statement, so a suspension
15
- * can never land between them. GREATEST keeps the newest interaction when
22
+ * The active-account check and write share one PostgreSQL statement snapshot.
23
+ * GREATEST keeps the newest interaction when
16
24
  * concurrent transactions commit out of order.
17
25
  */
18
26
  recordInteraction(userId: string, interaction: ChannelInteraction): Promise<void>;
19
27
  getHistory(userId: string, limit: number): Promise<readonly ChannelHistoryEntry[]>;
20
28
  }
29
+ /**
30
+ * Legacy PostgresIdentityStore - renamed to ApplicationIdentityStore for clarity.
31
+ * Kept for backward compatibility.
32
+ */
33
+ export declare const PostgresIdentityStore: typeof ApplicationIdentityStore;
@@ -1,17 +1,29 @@
1
1
  import { INACTIVE_ACCOUNT_ERROR, } from "./identity-provider.js";
2
- export class PostgresIdentityStore {
2
+ /**
3
+ * Application-specific identity store.
4
+ *
5
+ * This store is for app-specific user data (application_users table).
6
+ * For central database user data, use CentralIdentityStore.
7
+ */
8
+ export class ApplicationIdentityStore {
3
9
  pool;
4
10
  constructor(pool) {
5
11
  this.pool = pool;
6
12
  }
13
+ /** Profile refresh only: account creation requires application-owned identity fields. */
14
+ async syncFromAuthGateway(userId, displayName) {
15
+ const result = await this.pool.query("UPDATE application_users SET display_name = $2, updated_at = now() WHERE id = $1", [userId, displayName]);
16
+ if (!result.rowCount)
17
+ throw new Error("Application account must be provisioned before profile sync.");
18
+ }
7
19
  async getIdentity(userId) {
8
20
  const result = await this.pool.query("SELECT id, display_name FROM application_users WHERE id = $1 AND status = 'active'", [userId]);
9
21
  const row = result.rows[0];
10
22
  return row ? { userId: String(row.id), displayName: String(row.display_name) } : undefined;
11
23
  }
12
24
  /**
13
- * The active-account check and the write are one statement, so a suspension
14
- * can never land between them. GREATEST keeps the newest interaction when
25
+ * The active-account check and write share one PostgreSQL statement snapshot.
26
+ * GREATEST keeps the newest interaction when
15
27
  * concurrent transactions commit out of order.
16
28
  */
17
29
  async recordInteraction(userId, interaction) {
@@ -30,6 +42,11 @@ export class PostgresIdentityStore {
30
42
  return result.rows.map(historyFromRow);
31
43
  }
32
44
  }
45
+ /**
46
+ * Legacy PostgresIdentityStore - renamed to ApplicationIdentityStore for clarity.
47
+ * Kept for backward compatibility.
48
+ */
49
+ export const PostgresIdentityStore = ApplicationIdentityStore;
33
50
  function historyFromRow(row) {
34
51
  return {
35
52
  channelId: String(row.channel_id),
@@ -0,0 +1,170 @@
1
+ syntax = "proto3";
2
+
3
+ package epic_urc;
4
+
5
+ service UrcAuthApi {
6
+ rpc HealthCheck(HealthCheckRequest) returns (HealthCheckResponse) {};
7
+ rpc StartAuthSession(StartAuthSessionRequest)
8
+ returns (StartAuthSessionResponse) {};
9
+ rpc GetAuthSession(GetAuthSessionRequest) returns (GetAuthSessionResponse) {};
10
+ rpc RefreshAuthSession(RefreshAuthSessionRequest)
11
+ returns (RefreshAuthSessionResponse) {};
12
+ rpc RevokeAuthSession(RevokeAuthSessionRequest)
13
+ returns (RevokeAuthSessionResponse) {};
14
+ rpc VerifyUser(VerifyUserRequest)
15
+ returns (VerifyUserResponse) {};
16
+
17
+ // For external authentication token login
18
+ rpc ExchangeExternalTokenForUserToken(ExchangeExternalTokenForUserTokenRequest) returns (ExchangeExternalTokenForUserTokenResponse) {};
19
+
20
+ // For API key login
21
+ rpc ExchangeAPIKeyForUserToken(ExchangeAPIKeyForUserTokenRequest) returns (ExchangeAPIKeyForUserTokenResponse) {};
22
+
23
+ // For token exchange
24
+ rpc ExchangeUserTokenForMultiresourceToken(
25
+ ExchangeUserTokenForMultiresourceTokenRequest)
26
+ returns (ExchangeUserTokenForMultiresourceTokenResponse) {};
27
+
28
+ // For permission checks
29
+ rpc CheckUserPermission(CheckUserPermissionRequest)
30
+ returns (CheckUserPermissionResponse) {};
31
+ rpc LookupUserPermissions(LookupUserPermissionsRequest)
32
+ returns (LookupUserPermissionsResponse) {};
33
+
34
+ // User metadata
35
+ rpc GetUserInfo(GetUserInfoRequest) returns (GetUserInfoResponse) {};
36
+ rpc GetUserId(GetUserIdRequest) returns (GetUserIdResponse) {};
37
+ rpc GetProviderUserId(GetProviderUserIdRequest) returns (GetProviderUserIdResponse) {};
38
+ }
39
+
40
+ message TargetUser {
41
+ oneof user {
42
+ string user_token = 1;
43
+ }
44
+ }
45
+
46
+ message HealthCheckRequest {}
47
+
48
+ message HealthCheckResponse { string status = 1; }
49
+
50
+ message StartAuthSessionRequest { string client_state = 1; }
51
+
52
+ message StartAuthSessionResponse {
53
+ string session_code = 1;
54
+ string login_url = 2;
55
+ }
56
+
57
+ message GetAuthSessionRequest {
58
+ string session_code = 1;
59
+ string client_state = 2;
60
+ }
61
+
62
+ message GetAuthSessionResponse { optional UserToken user_token = 1; }
63
+
64
+ message RefreshAuthSessionRequest {}
65
+
66
+ message RefreshAuthSessionResponse { optional UserToken user_token = 1; }
67
+
68
+ message RevokeAuthSessionRequest {}
69
+
70
+ message RevokeAuthSessionResponse {}
71
+
72
+ // Verify the target is compliant with the given Product Name as registered in `ucs-auth-api` config
73
+ message VerifyCompliance {
74
+ string product_name = 1;
75
+ }
76
+ message VerifyRequirement {
77
+ reserved 1; // deprecated token
78
+ oneof requirement {
79
+ VerifyCompliance compliance = 2;
80
+ };
81
+ }
82
+
83
+ message VerifyUserRequest {
84
+ TargetUser target_user = 1;
85
+ repeated VerifyRequirement requirements = 2;
86
+ }
87
+ message VerifyUserResponse {
88
+ UserInfo user_info = 1;
89
+ }
90
+
91
+ message ExchangeExternalTokenForUserTokenRequest { string external_token = 1; string token_type = 2; }
92
+
93
+ message ExchangeExternalTokenForUserTokenResponse { optional UserToken user_token = 1; }
94
+
95
+ message ExchangeAPIKeyForUserTokenRequest { string api_key = 1; }
96
+
97
+ message ExchangeAPIKeyForUserTokenResponse { optional UserToken user_token = 1; }
98
+
99
+ message UserToken {
100
+ string user_token = 1;
101
+ int64 expires_at = 2; // Epoch
102
+ string user_id = 3; // URC user ID
103
+ string user_name = 4; // Display name
104
+ optional string refresh_token = 5; // Opaque rotating refresh credential; only present on auth-session creation/refresh
105
+ }
106
+
107
+ message ResourcePermission {
108
+ string resource_id = 1;
109
+ repeated string permission = 2;
110
+ }
111
+
112
+ message ExchangeUserTokenForMultiresourceTokenRequest {
113
+ repeated string resource_id = 1;
114
+ }
115
+
116
+ message ExchangeUserTokenForMultiresourceTokenResponse { UserToken token = 1; }
117
+
118
+ message CheckUserPermissionRequest {
119
+ repeated string resource_id = 1;
120
+ optional TargetUser target_user = 2;
121
+ }
122
+
123
+ message CheckUserPermissionResponse {
124
+ repeated ResourcePermission allowed_resource_permission = 1;
125
+ repeated ResourcePermission denied_resource_permission = 2;
126
+ }
127
+
128
+ message LookupUserPermissionsRequest {
129
+ string resource_filter = 1;
130
+ optional string context_filter = 2;
131
+ optional int32 page_size = 3;
132
+ optional string page_token = 4;
133
+ }
134
+
135
+ message LookupUserPermissionsResponse {
136
+ repeated ResourcePermission resource_permission = 1;
137
+ optional string next_page_token = 2;
138
+ }
139
+
140
+ message GetUserInfoRequest {
141
+ string resource_id = 1;
142
+ repeated string user_id = 2;
143
+ }
144
+
145
+ message UserInfo {
146
+ string user_id = 1;
147
+ string display_name = 2;
148
+ }
149
+
150
+ message GetUserInfoResponse {
151
+ repeated UserInfo user_info = 1;
152
+ }
153
+
154
+ message GetUserIdRequest {
155
+ string resource_id = 1;
156
+ string user_display_name = 2;
157
+ }
158
+
159
+ message GetUserIdResponse {
160
+ UserInfo user_info = 1;
161
+ }
162
+
163
+ message GetProviderUserIdRequest {
164
+ string user_id = 1;
165
+ }
166
+
167
+ message GetProviderUserIdResponse {
168
+ string user_id = 1;
169
+ string provider_user_id = 2;
170
+ }
@@ -0,0 +1,113 @@
1
+ # Identity integration guide
2
+
3
+ ## Choose the owning database
4
+
5
+ Use the central adapter when this application owns shared identity provisioning. Use the
6
+ application adapter when Portals has already created an application account. A custom
7
+ `IdentityStore` is appropriate for consumers that only cache sightings and delegate history
8
+ to the platform. Keep token verification on the server in every case.
9
+
10
+ The storage seam has two concrete adapters; both expose the same profile-sync capability.
11
+ The provider handles upstream subject matching once, so callers do not implement store-specific
12
+ fallbacks. Unsupported custom stores fail explicitly instead of silently ignoring sync.
13
+
14
+ ## Central setup: runnable server-side example
15
+
16
+ Install `pg` and this package. Configure the central database URL, auth-gateway address,
17
+ resource ID, and TLS. Run central provisioning with DDL credentials before serving traffic.
18
+ This example accepts a **server-obtained test token** and explicitly provisions its verified
19
+ subject. It is an onboarding operation, not middleware to run on every request.
20
+
21
+ ```typescript
22
+ import pg from "pg";
23
+ import {
24
+ CentralIdentityStore, IdentityProvider, createAuthGatewayClient, runCentralMigrations,
25
+ } from "@portalshq/capability-identity";
26
+
27
+ const pool = new pg.Pool({ connectionString: process.env.CENTRAL_DATABASE_URL });
28
+ const gateway = createAuthGatewayClient({
29
+ address: process.env.AUTH_GATEWAY_ADDRESS!,
30
+ useTls: process.env.AUTH_GATEWAY_TLS === "true",
31
+ timeout: 5000,
32
+ });
33
+ try {
34
+ await runCentralMigrations(pool); // deployment setup; use a separate DDL role in production
35
+ const provider = new IdentityProvider(
36
+ async (token) => (await gateway.verifyUser(token)).userId,
37
+ new CentralIdentityStore(pool),
38
+ );
39
+ const token = process.env.IDENTITY_AUTH_GATEWAY_TEST_TOKEN!;
40
+ const verified = await gateway.verifyUser(token);
41
+ await provider.syncFromAuthGateway(verified.userId, gateway, process.env.AUTH_GATEWAY_RESOURCE_ID!);
42
+ const identity = await provider.getIdentity(token);
43
+ await provider.recordInteraction(token, { channelId: "example:channel", sessionId: "example:session" });
44
+ const history = await provider.getHistory(token, 25);
45
+ console.log(identity.displayName, history.length);
46
+ } finally {
47
+ gateway.close();
48
+ await pool.end();
49
+ }
50
+ ```
51
+
52
+ Application setup uses `new ApplicationIdentityStore(pool)` with the same provider.
53
+ Provision the account through Portals before calling sync. Never fill required encrypted
54
+ identity fields with empty strings or invented data just to make an insert succeed.
55
+ Both adapters preserve suspended status during profile updates.
56
+
57
+ ## Verification and request handling
58
+
59
+ Extract the request credential, validate it through `verifyUser`, and let `IdentityProvider`
60
+ check the local active account. Return an authentication/authorization failure when verification
61
+ or active-account lookup fails. Treat database and gateway availability errors according to
62
+ your request error policy; do not translate outages into successful anonymous identities.
63
+ Use gRPC error `code` for classification. The package performs no automatic retries.
64
+
65
+ Run sync in a trusted onboarding/admin flow with a verified subject or an operator-authorized
66
+ subject. `getUserInfo` is a metadata lookup, not proof that the requesting caller owns that ID.
67
+ Soft references only establish existence and cannot approve commercial or access transitions.
68
+
69
+ ## Existing central schema upgrade
70
+
71
+ Back up, stop incompatible writers, and run `runCentralMigrations` on the central database.
72
+ The migration is repeatable, serializes concurrent runs, and rolls back all changes on failure.
73
+ Existing issuer/email/preferences/history data stays intact. Legacy history with duplicate
74
+ `(user_id, channel_id, session_id)` values across apps must be reconciled by the operator.
75
+ Null sessions and invalid soft IDs must also be resolved explicitly. Globally namespace channel
76
+ and session IDs when different applications previously reused them. Verify the resulting
77
+ history before restarting writers. Do not run the central migration on the platform application
78
+ database or an unrelated database with a table called `users`.
79
+
80
+ For SQL-only deployment, run the package's `sql/001_central_identity.sql` using
81
+ `psql -v ON_ERROR_STOP=1 --single-transaction`; serialize manual deployments yourself.
82
+ The TypeScript runner handles serialization automatically. No migration runs on import.
83
+
84
+ ## Moving from custom stores
85
+
86
+ First establish whether the custom store owns identities or is merely a cache. For an owning
87
+ store, back up and map subject IDs without changing them; provision required account fields
88
+ through the owning application's workflow. Import history preserving its original timestamps
89
+ in an operator-controlled migration. `recordInteraction` intentionally cannot backdate events.
90
+ Switch the provider's adapter only after comparing identities, active status, and history.
91
+
92
+ For a cache, retain its existing `IdentityStore` implementation. Implement profile sync only
93
+ if it has a meaningful persistent effect; an unsupported sync operation should fail explicitly.
94
+ Do not replace a consumer cache with an authoritative store just to reuse a class.
95
+
96
+ ### bears-live-app assessment
97
+
98
+ No bears-live-app migration is included. The described `AudienceViewerStore` pattern—cache
99
+ viewers on sight, delegate authoritative verification and history to the platform—is compatible
100
+ with the custom-store seam. It serves a different ownership role from either PostgreSQL adapter.
101
+ The implementation was not found in this checkout, so its exact behavior is not verified here.
102
+ Before changing that application, review its actual token validation and platform-history path.
103
+
104
+ ## Operational checklist
105
+
106
+ Use TLS to the gateway outside a trusted private network, TLS to the database, a finite call
107
+ deadline, and separate migration/runtime database roles. Monitor failed lookups and syncs
108
+ without logging credentials. Close the client and pool during graceful shutdown. Back up
109
+ before legacy migration; restore from backup if an incompatible release must be rolled back.
110
+ No package build, test, or migration runner provisions a remote production database implicitly.
111
+
112
+ See the [README](../README.md) for the complete interface, environment settings, troubleshooting,
113
+ and commands for PostgreSQL, gRPC, and coverage verification.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@portalshq/capability-identity",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/portalshq/portals-cloud.git",
@@ -12,19 +12,28 @@
12
12
  "types": "dist/index.d.ts",
13
13
  "files": [
14
14
  "dist",
15
- "README.md"
15
+ "README.md",
16
+ "sql",
17
+ "docs",
18
+ "CHANGELOG.md"
16
19
  ],
17
20
  "scripts": {
18
- "build": "rm -rf dist && tsc -p tsconfig.json",
21
+ "build": "rm -rf dist && tsc -p tsconfig.json && node scripts/copy-proto.mjs",
19
22
  "typecheck": "tsc -p tsconfig.json --noEmit",
20
- "test": "vitest run"
23
+ "test": "npm run build && vitest run",
24
+ "test:coverage": "npm run build && vitest run --coverage"
25
+ },
26
+ "dependencies": {
27
+ "@grpc/grpc-js": "^1.11.3",
28
+ "@grpc/proto-loader": "^0.7.13"
21
29
  },
22
30
  "devDependencies": {
23
31
  "typescript": "^5.5.0",
24
32
  "@types/node": "^20.0.0",
25
33
  "@types/pg": "^8.15.5",
26
34
  "pg": "^8.16.3",
27
- "vitest": "^3.2.7"
35
+ "vitest": "^3.2.7",
36
+ "@vitest/coverage-v8": "^3.2.7"
28
37
  },
29
38
  "publishConfig": {
30
39
  "access": "public",
@@ -0,0 +1,93 @@
1
+ -- Central database schema for cross-app identity
2
+ -- This schema is for the central Portals database that stores user data
3
+ -- shared across all applications.
4
+
5
+ -- User registry (all apps)
6
+ CREATE TABLE IF NOT EXISTS users (
7
+ id text PRIMARY KEY, -- auth-gateway subject_id
8
+ display_name text NOT NULL,
9
+ email text,
10
+ status text NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'suspended')),
11
+ preferred_username text,
12
+ idp text, -- auth-gateway issuer
13
+ created_at timestamptz NOT NULL DEFAULT now(),
14
+ updated_at timestamptz NOT NULL DEFAULT now()
15
+ );
16
+
17
+ -- User preferences (all apps)
18
+ CREATE TABLE IF NOT EXISTS user_preferences (
19
+ user_id text NOT NULL REFERENCES users(id) ON DELETE CASCADE,
20
+ key text NOT NULL,
21
+ value jsonb NOT NULL,
22
+ updated_at timestamptz NOT NULL DEFAULT now(),
23
+ PRIMARY KEY (user_id, key)
24
+ );
25
+
26
+ -- Channel registry (all apps)
27
+ CREATE TABLE IF NOT EXISTS channel_apps (
28
+ id text PRIMARY KEY, -- App ID
29
+ name text NOT NULL,
30
+ tenant_id text NOT NULL, -- For cross-app analytics
31
+ created_at timestamptz NOT NULL DEFAULT now()
32
+ );
33
+
34
+ -- Channel schedules (all apps)
35
+ CREATE TABLE IF NOT EXISTS channel_schedules (
36
+ id text PRIMARY KEY,
37
+ app_id text NOT NULL REFERENCES channel_apps(id) ON DELETE CASCADE,
38
+ channel_id text NOT NULL,
39
+ schedule jsonb NOT NULL,
40
+ created_at timestamptz NOT NULL DEFAULT now(),
41
+ updated_at timestamptz NOT NULL DEFAULT now()
42
+ );
43
+
44
+ -- Channel sessions (all apps)
45
+ CREATE TABLE IF NOT EXISTS channel_sessions (
46
+ id text PRIMARY KEY,
47
+ app_id text NOT NULL REFERENCES channel_apps(id) ON DELETE CASCADE,
48
+ channel_id text NOT NULL,
49
+ starts_at timestamptz NOT NULL,
50
+ ends_at timestamptz NOT NULL,
51
+ metadata jsonb,
52
+ created_at timestamptz NOT NULL DEFAULT now()
53
+ );
54
+
55
+ -- Cross-app user history (for analytics)
56
+ CREATE TABLE IF NOT EXISTS user_channel_history (
57
+ id bigserial PRIMARY KEY,
58
+ user_id text NOT NULL REFERENCES users(id) ON DELETE CASCADE,
59
+ app_id text REFERENCES channel_apps(id) ON DELETE CASCADE,
60
+ channel_id text NOT NULL,
61
+ session_id text NOT NULL,
62
+ last_interaction_at timestamptz NOT NULL DEFAULT now(),
63
+ metadata jsonb,
64
+ UNIQUE (user_id, channel_id, session_id)
65
+ );
66
+
67
+ CREATE INDEX IF NOT EXISTS user_channel_history_user_idx ON user_channel_history(user_id);
68
+ CREATE INDEX IF NOT EXISTS user_channel_history_app_idx ON user_channel_history(app_id);
69
+
70
+ -- Upgrade the previously distributed schema without deleting historical data.
71
+ ALTER TABLE users ADD COLUMN IF NOT EXISTS status text NOT NULL DEFAULT 'active'
72
+ CHECK (status IN ('active', 'suspended'));
73
+ ALTER TABLE users ALTER COLUMN idp DROP NOT NULL;
74
+ ALTER TABLE user_channel_history ALTER COLUMN app_id DROP NOT NULL;
75
+ -- Duplicate legacy soft keys must be reconciled by an operator before upgrading.
76
+ -- A unique-index failure rolls back the entire migration; history is never discarded.
77
+ CREATE UNIQUE INDEX IF NOT EXISTS user_channel_history_soft_key_idx
78
+ ON user_channel_history(user_id, channel_id, session_id);
79
+ CREATE INDEX IF NOT EXISTS user_channel_history_recent_idx
80
+ ON user_channel_history(user_id, last_interaction_at DESC);
81
+
82
+ ALTER TABLE user_channel_history ALTER COLUMN session_id SET NOT NULL;
83
+ DO $history_constraints$
84
+ BEGIN
85
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conrelid = 'user_channel_history'::regclass AND conname = 'identity_history_channel_length') THEN
86
+ ALTER TABLE user_channel_history ADD CONSTRAINT identity_history_channel_length
87
+ CHECK (length(btrim(channel_id)) BETWEEN 1 AND 128);
88
+ END IF;
89
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conrelid = 'user_channel_history'::regclass AND conname = 'identity_history_session_length') THEN
90
+ ALTER TABLE user_channel_history ADD CONSTRAINT identity_history_session_length
91
+ CHECK (length(btrim(session_id)) BETWEEN 1 AND 128);
92
+ END IF;
93
+ END $history_constraints$;