@portalshq/capability-identity 0.1.2 → 0.2.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/README.md CHANGED
@@ -1,5 +1,33 @@
1
1
  # @portalshq/capability-identity
2
2
 
3
- Cross-channel audience identity. Every other capability that needs to know
4
- "who is this audience member" depends on this rather than rolling its own
5
- auth — that's the only way a persistent cross-channel profile is possible.
3
+ **Reusable agent module:** [integration guide](../../../docs/package-agents/identity.md)
4
+
5
+ Verified application identity and persistent cross-channel interaction history.
6
+
7
+ ## Exported shape
8
+
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.
16
+
17
+ ## Setup
18
+
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.
23
+
24
+ ## Limits
25
+
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).
29
+
30
+ ## Tests
31
+
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.
@@ -9,13 +9,32 @@ export interface AudienceIdentity {
9
9
  userId: string;
10
10
  displayName: string;
11
11
  }
12
- export interface ChannelHistoryEntry {
12
+ /** Soft cross-database key; the matching channel row may live elsewhere. */
13
+ export interface ChannelInteraction {
13
14
  channelId: string;
14
15
  sessionId: string;
16
+ }
17
+ export interface ChannelHistoryEntry extends ChannelInteraction {
15
18
  lastInteractionAt: string;
16
19
  }
20
+ /** Matches the identifier bounds in the platform migration. */
21
+ export declare const MAX_IDENTIFIER_LENGTH = 128;
22
+ export declare const MAX_HISTORY_ENTRIES = 500;
23
+ export declare const DEFAULT_HISTORY_ENTRIES = 100;
24
+ export declare const INACTIVE_ACCOUNT_ERROR = "The verified user is not linked to an active application account.";
25
+ export interface IdentityStore {
26
+ getIdentity(userId: string): Promise<AudienceIdentity | undefined>;
27
+ recordInteraction(userId: string, interaction: ChannelInteraction): Promise<void>;
28
+ getHistory(userId: string, limit: number): Promise<readonly ChannelHistoryEntry[]>;
29
+ }
30
+ /** Must validate the credential and return an active application user ID. */
31
+ export type VerifyAccessToken = (accessToken: string) => Promise<string>;
17
32
  export declare class IdentityProvider {
18
- getIdentity(userId: string): Promise<AudienceIdentity>;
19
- recordInteraction(entry: ChannelHistoryEntry): Promise<void>;
20
- getHistory(userId: string): Promise<ChannelHistoryEntry[]>;
33
+ private readonly verifyAccessToken;
34
+ private readonly store;
35
+ constructor(verifyAccessToken: VerifyAccessToken, store: IdentityStore);
36
+ getIdentity(accessToken: string): Promise<AudienceIdentity>;
37
+ /** Interaction time is the storage transaction's clock, never the caller's. */
38
+ recordInteraction(accessToken: string, interaction: ChannelInteraction): Promise<void>;
39
+ getHistory(accessToken: string, limit?: number): Promise<readonly ChannelHistoryEntry[]>;
21
40
  }
@@ -1,11 +1,42 @@
1
+ /** Matches the identifier bounds in the platform migration. */
2
+ export const MAX_IDENTIFIER_LENGTH = 128;
3
+ export const MAX_HISTORY_ENTRIES = 500;
4
+ export const DEFAULT_HISTORY_ENTRIES = 100;
5
+ export const INACTIVE_ACCOUNT_ERROR = "The verified user is not linked to an active application account.";
1
6
  export class IdentityProvider {
2
- async getIdentity(userId) {
3
- throw new Error("IdentityProvider.getIdentity: not yet implemented");
7
+ verifyAccessToken;
8
+ store;
9
+ constructor(verifyAccessToken, store) {
10
+ this.verifyAccessToken = verifyAccessToken;
11
+ this.store = store;
4
12
  }
5
- async recordInteraction(entry) {
6
- throw new Error("IdentityProvider.recordInteraction: not yet implemented");
13
+ async getIdentity(accessToken) {
14
+ const userId = await this.verifyAccessToken(accessToken);
15
+ const identity = await this.store.getIdentity(userId);
16
+ if (!identity)
17
+ throw new Error(INACTIVE_ACCOUNT_ERROR);
18
+ return identity;
7
19
  }
8
- async getHistory(userId) {
9
- throw new Error("IdentityProvider.getHistory: not yet implemented");
20
+ /** Interaction time is the storage transaction's clock, never the caller's. */
21
+ async recordInteraction(accessToken, interaction) {
22
+ assertIdentifier(interaction.channelId, "channelId");
23
+ assertIdentifier(interaction.sessionId, "sessionId");
24
+ const identity = await this.getIdentity(accessToken);
25
+ await this.store.recordInteraction(identity.userId, interaction);
10
26
  }
27
+ async getHistory(accessToken, limit = DEFAULT_HISTORY_ENTRIES) {
28
+ const identity = await this.getIdentity(accessToken);
29
+ return this.store.getHistory(identity.userId, historyLimit(limit));
30
+ }
31
+ }
32
+ function assertIdentifier(value, field) {
33
+ const trimmed = value?.trim() ?? "";
34
+ if (!trimmed || trimmed.length > MAX_IDENTIFIER_LENGTH) {
35
+ throw new TypeError(`${field} must be 1-${MAX_IDENTIFIER_LENGTH} characters.`);
36
+ }
37
+ }
38
+ function historyLimit(limit) {
39
+ if (!Number.isInteger(limit) || limit < 1)
40
+ throw new TypeError("History limit must be a positive integer.");
41
+ return Math.min(limit, MAX_HISTORY_ENTRIES);
11
42
  }
package/dist/index.d.ts CHANGED
@@ -1 +1,2 @@
1
1
  export * from "./identity-provider.js";
2
+ export * from "./postgres-store.js";
package/dist/index.js CHANGED
@@ -1 +1,2 @@
1
1
  export * from "./identity-provider.js";
2
+ export * from "./postgres-store.js";
@@ -0,0 +1,20 @@
1
+ import { type AudienceIdentity, type ChannelHistoryEntry, type ChannelInteraction, type IdentityStore } from "./identity-provider.js";
2
+ /** Minimal shape of `pg.Pool`, so the published types need no pg typings. */
3
+ export interface QueryRunner {
4
+ query(text: string, values?: unknown[]): Promise<{
5
+ rowCount: number | null;
6
+ rows: Array<Record<string, unknown>>;
7
+ }>;
8
+ }
9
+ export declare class PostgresIdentityStore implements IdentityStore {
10
+ private readonly pool;
11
+ constructor(pool: QueryRunner);
12
+ getIdentity(userId: string): Promise<AudienceIdentity | undefined>;
13
+ /**
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
16
+ * concurrent transactions commit out of order.
17
+ */
18
+ recordInteraction(userId: string, interaction: ChannelInteraction): Promise<void>;
19
+ getHistory(userId: string, limit: number): Promise<readonly ChannelHistoryEntry[]>;
20
+ }
@@ -0,0 +1,39 @@
1
+ import { INACTIVE_ACCOUNT_ERROR, } from "./identity-provider.js";
2
+ export class PostgresIdentityStore {
3
+ pool;
4
+ constructor(pool) {
5
+ this.pool = pool;
6
+ }
7
+ async getIdentity(userId) {
8
+ const result = await this.pool.query("SELECT id, display_name FROM application_users WHERE id = $1 AND status = 'active'", [userId]);
9
+ const row = result.rows[0];
10
+ return row ? { userId: String(row.id), displayName: String(row.display_name) } : undefined;
11
+ }
12
+ /**
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
15
+ * concurrent transactions commit out of order.
16
+ */
17
+ async recordInteraction(userId, interaction) {
18
+ const result = await this.pool.query(`INSERT INTO application_user_channel_history (user_id, channel_id, session_id, last_interaction_at)
19
+ SELECT $1, $2, $3, now()
20
+ WHERE EXISTS (SELECT 1 FROM application_users WHERE id = $1 AND status = 'active')
21
+ ON CONFLICT (user_id, channel_id, session_id) DO UPDATE
22
+ SET last_interaction_at = GREATEST(application_user_channel_history.last_interaction_at, EXCLUDED.last_interaction_at)`, [userId, interaction.channelId, interaction.sessionId]);
23
+ if (!result.rowCount)
24
+ throw new Error(INACTIVE_ACCOUNT_ERROR);
25
+ }
26
+ async getHistory(userId, limit) {
27
+ const result = await this.pool.query(`SELECT channel_id, session_id, last_interaction_at
28
+ FROM application_user_channel_history
29
+ WHERE user_id = $1 ORDER BY last_interaction_at DESC LIMIT $2`, [userId, limit]);
30
+ return result.rows.map(historyFromRow);
31
+ }
32
+ }
33
+ function historyFromRow(row) {
34
+ return {
35
+ channelId: String(row.channel_id),
36
+ sessionId: String(row.session_id),
37
+ lastInteractionAt: new Date(row.last_interaction_at).toISOString(),
38
+ };
39
+ }
package/package.json CHANGED
@@ -1,28 +1,30 @@
1
1
  {
2
2
  "name": "@portalshq/capability-identity",
3
- "version": "0.1.2",
3
+ "version": "0.2.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/portalshq/portals-cloud.git",
7
- "directory": "packages/stubs/identity"
7
+ "directory": "packages/capability-identity"
8
8
  },
9
9
  "description": "Federated audience identity, persistent across every channel — current and future, platform-provided and developer-created.",
10
10
  "type": "module",
11
11
  "main": "dist/index.js",
12
12
  "types": "dist/index.d.ts",
13
13
  "files": [
14
- "dist"
14
+ "dist",
15
+ "README.md"
15
16
  ],
16
17
  "scripts": {
17
- "build": "tsc -p tsconfig.json",
18
- "typecheck": "tsc -p tsconfig.json --noEmit"
19
- },
20
- "dependencies": {
21
- "@portalshq/contracts": "*"
18
+ "build": "rm -rf dist && tsc -p tsconfig.json",
19
+ "typecheck": "tsc -p tsconfig.json --noEmit",
20
+ "test": "vitest run"
22
21
  },
23
22
  "devDependencies": {
24
23
  "typescript": "^5.5.0",
25
- "@types/node": "^20.0.0"
24
+ "@types/node": "^20.0.0",
25
+ "@types/pg": "^8.15.5",
26
+ "pg": "^8.16.3",
27
+ "vitest": "^3.2.7"
26
28
  },
27
29
  "publishConfig": {
28
30
  "access": "public",