@portalshq/capability-identity 0.2.0 → 0.3.0
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 +53 -0
- package/README.md +194 -22
- package/dist/auth-gateway-client.d.ts +19 -0
- package/dist/auth-gateway-client.js +41 -0
- package/dist/central-store.d.ts +39 -0
- package/dist/central-store.js +78 -0
- package/dist/identity-provider.d.ts +37 -1
- package/dist/identity-provider.js +53 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/migrations.d.ts +6 -0
- package/dist/migrations.js +12 -0
- package/dist/postgres-store.d.ts +17 -4
- package/dist/postgres-store.js +20 -3
- package/dist/proto/auth_api.proto +170 -0
- package/docs/integration-guide.md +113 -0
- package/package.json +14 -5
- package/sql/001_central_identity.sql +93 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# @portalshq/capability-identity
|
|
2
|
+
|
|
3
|
+
## 0.3.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 31b4bb6: 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.
|
|
8
|
+
|
|
9
|
+
## 0.2.1
|
|
10
|
+
|
|
11
|
+
- Implement typed explicit auth-gateway sync with subject matching and clear unsupported-store errors.
|
|
12
|
+
- Refresh existing application profiles without inventing required private identity fields;
|
|
13
|
+
provision central profiles while preserving existing suspension, issuer, email, and preferences.
|
|
14
|
+
- Bundle the canonical proto in the built package, add configurable proto paths and RPC deadlines,
|
|
15
|
+
preserve gRPC status errors, and close connections idempotently.
|
|
16
|
+
- Ship atomic, concurrency-serialized central provisioning and non-destructive legacy upgrades.
|
|
17
|
+
Align preference reads and history conflict keys with the actual central schema.
|
|
18
|
+
- Add real gRPC transport, isolated PostgreSQL, sync/error, migration upgrade/rollback, and
|
|
19
|
+
public-export tests. Enforce 95% runtime-source coverage thresholds.
|
|
20
|
+
- Document deployment, migrations, the full interface, troubleshooting, and custom-store ownership.
|
|
21
|
+
No bears-live-app migration is performed.
|
|
22
|
+
|
|
23
|
+
## 0.2.0
|
|
24
|
+
|
|
25
|
+
### Minor Changes
|
|
26
|
+
|
|
27
|
+
- 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.
|
|
28
|
+
|
|
29
|
+
## 0.1.3
|
|
30
|
+
|
|
31
|
+
### Patch Changes
|
|
32
|
+
|
|
33
|
+
- 749ad63: Standardize public npm publishing and repository metadata for releases through
|
|
34
|
+
GitHub Actions trusted publishing. Remove recursive publish lifecycle hooks.
|
|
35
|
+
- Updated dependencies [749ad63]
|
|
36
|
+
- @portalshq/contracts@0.0.5
|
|
37
|
+
|
|
38
|
+
## 0.1.2
|
|
39
|
+
|
|
40
|
+
### Patch Changes
|
|
41
|
+
|
|
42
|
+
- aff6c1d: Standardize public npm publishing and repository metadata for releases through
|
|
43
|
+
GitHub Actions trusted publishing. Remove recursive publish lifecycle hooks.
|
|
44
|
+
- Updated dependencies [aff6c1d]
|
|
45
|
+
- @portalshq/contracts@0.0.4
|
|
46
|
+
|
|
47
|
+
## 0.1.1
|
|
48
|
+
|
|
49
|
+
### Patch Changes
|
|
50
|
+
|
|
51
|
+
- 8884dd5: feat: add ESM build infrastructure to all packages
|
|
52
|
+
- Updated dependencies [8884dd5]
|
|
53
|
+
- @portalshq/contracts@0.0.3
|
package/README.md
CHANGED
|
@@ -1,33 +1,205 @@
|
|
|
1
1
|
# @portalshq/capability-identity
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Server-side verified identity and cross-channel history. Requires Node.js 22 and PostgreSQL 14+.
|
|
4
4
|
|
|
5
|
-
|
|
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
|
-
##
|
|
10
|
+
## Setup and deployment
|
|
8
11
|
|
|
9
|
-
|
|
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
|
-
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
`
|
|
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
|
-
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
31
|
+
```typescript
|
|
32
|
+
import pg from "pg";
|
|
33
|
+
import {
|
|
34
|
+
CentralIdentityStore, IdentityProvider, createAuthGatewayClient,
|
|
35
|
+
} from "@portalshq/capability-identity";
|
|
29
36
|
|
|
30
|
-
|
|
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
|
-
|
|
33
|
-
|
|
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
|
-
|
|
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
package/dist/index.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/postgres-store.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type AudienceIdentity, type ChannelHistoryEntry, type ChannelInteraction, type
|
|
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
|
-
|
|
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
|
|
15
|
-
*
|
|
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;
|
package/dist/postgres-store.js
CHANGED
|
@@ -1,17 +1,29 @@
|
|
|
1
1
|
import { INACTIVE_ACCOUNT_ERROR, } from "./identity-provider.js";
|
|
2
|
-
|
|
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
|
|
14
|
-
*
|
|
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.
|
|
3
|
+
"version": "0.3.0",
|
|
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$;
|