@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 +31 -3
- package/dist/identity-provider.d.ts +23 -4
- package/dist/identity-provider.js +37 -6
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/postgres-store.d.ts +20 -0
- package/dist/postgres-store.js +39 -0
- package/package.json +11 -9
package/README.md
CHANGED
|
@@ -1,5 +1,33 @@
|
|
|
1
1
|
# @portalshq/capability-identity
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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
|
-
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
3
|
-
|
|
7
|
+
verifyAccessToken;
|
|
8
|
+
store;
|
|
9
|
+
constructor(verifyAccessToken, store) {
|
|
10
|
+
this.verifyAccessToken = verifyAccessToken;
|
|
11
|
+
this.store = store;
|
|
4
12
|
}
|
|
5
|
-
async
|
|
6
|
-
|
|
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
|
-
|
|
9
|
-
|
|
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
package/dist/index.js
CHANGED
|
@@ -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.
|
|
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/
|
|
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",
|