Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,11 @@ const project = new Client({
Project tokens require an explicit project ID. The server verifies the initial
token-to-project binding. A later attempt to bind that client to another
project fails before transport. `Client` also rejects browser client tokens and
the CLI-only `pmfa_ls_` listener credential before transport.
the CLI-only `pmfa_ls_` listener credential before transport. Organization
keys must use the single v1 form `pmfa_` plus 72 unpadded base64url characters;
project tokens must use `pmfa_pt_` plus 94. The SDK validates that grammar
without decoding the credential. Call-agent tickets, socket tickets, and
simulated-device capabilities are also rejected before transport.

Both organization and project views expose owner-bound resources:

Expand Down
5 changes: 4 additions & 1 deletion packages/nextjs/test/nextjs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,10 @@ describe("createMessagingClientTokenMint", () => {
describe("createTemplateBuilderRoute", () => {
it("accepts the handwritten server SDK templates resource without an adapter", () => {
const messaging = new MessagingClient({
credential: { type: "apiKey", value: "pmfa_fixture" },
credential: {
type: "apiKey",
value: `pmfa_${"A".repeat(72)}`,
},
});
expect(() =>
createTemplateBuilderRoute({
Expand Down
7 changes: 6 additions & 1 deletion packages/typescript/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,9 +70,14 @@ have a different authorization boundary.
Messaging credentials are explicit: `apiKey` for an organization server key,
`projectToken` for the single opaque project-token format, and `clientToken`
for the browser action allowlist. Server credentials fail in browser runtimes.
An organization key is exactly `pmfa_` plus 72 unpadded base64url characters;
a project token is exactly `pmfa_pt_` plus 94. The SDK checks only this public
v1 grammar and never decodes or decrypts the credential.

The SDK rejects `pmfa_ct_` browser tokens and CLI-only `pmfa_ls_` listener
credentials before a management request. It does not expose a listener,
credentials before a management request. It also rejects call-agent
`pmfa_at_` tickets, socket `pmfa_wst_` tickets, and simulated-device `pmfa_sd_`
capabilities as server API keys. It does not expose a listener,
`AsyncIterable`, event emitter, or forwarding API. Live forwarding belongs to
`polymorfa listen`.

Expand Down
52 changes: 31 additions & 21 deletions packages/typescript/src/credentials.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { PolymorfaConfigurationError } from "./errors.js";

const ORGANIZATION_API_KEY_V1 = /^pmfa_[A-Za-z0-9_-]{72}$/;
const PROJECT_TOKEN_V1 = /^pmfa_pt_[A-Za-z0-9_-]{93}[AQgw]$/;

export type MessagingCredential =
| { readonly type: "apiKey"; readonly value: string }
| { readonly type: "projectToken"; readonly value: string }
Expand Down Expand Up @@ -40,10 +43,11 @@ export type ClientOptions =
export function validateMessagingCredential(
credential: MessagingCredential,
): MessagingCredential {
rejectListenerCredential(credential.value);
if (credential.type === "projectToken") {
if (!isProjectToken(credential.value)) {
throw new PolymorfaConfigurationError(
"Messaging project tokens must use the pmfa_pt_ prefix.",
"Messaging project tokens must use the canonical pmfa_pt_ v1 format.",
"credential",
);
}
Expand All @@ -62,12 +66,7 @@ export function validateMessagingCredential(
return credential;
}

if (!isServerApiKey(credential.value)) {
throw new PolymorfaConfigurationError(
"Messaging API key must be a pmfa_ server API key.",
"credential",
);
}
validateOrganizationApiKey(credential.value);
return credential;
}

Expand All @@ -76,12 +75,9 @@ export function validateClientCredential(
): ClientCredential {
rejectListenerCredential(credential.value);
if (credential.type === "projectToken") {
if (
!credential.value.startsWith("pmfa_pt_") ||
credential.value.length <= "pmfa_pt_".length
) {
if (!isProjectToken(credential.value)) {
throw new PolymorfaConfigurationError(
"Project tokens must use the pmfa_pt_ prefix.",
"Project tokens must use the canonical pmfa_pt_ v1 format.",
"credential",
);
}
Expand All @@ -93,9 +89,10 @@ export function validateClientCredential(

export function validateOrganizationApiKey(value: string): string {
rejectListenerCredential(value);
rejectNonOrganizationCredential(value);
if (!isServerApiKey(value)) {
throw new PolymorfaConfigurationError(
"Organization server API keys must use the pmfa_ prefix.",
"Organization server API keys must use the canonical pmfa_ v1 format.",
"credential",
);
}
Expand All @@ -111,6 +108,25 @@ function rejectListenerCredential(value: string): void {
}
}

function rejectNonOrganizationCredential(value: string): void {
if (value.startsWith("pmfa_ct_") || value.startsWith("pmfa_pt_")) {
throw new PolymorfaConfigurationError(
"Client and project tokens cannot be used as organization server API keys.",
"credential",
);
}
if (
value.startsWith("pmfa_at_") ||
value.startsWith("pmfa_wst_") ||
value.startsWith("pmfa_sd_")
) {
throw new PolymorfaConfigurationError(
"Special-purpose tickets and capabilities cannot be used as organization server API keys.",
"credential",
);
}
}

export function assertServerRuntime(
runtime: {
readonly window?: unknown;
Expand All @@ -135,15 +151,9 @@ export function assertServerRuntime(
}

function isServerApiKey(value: string): boolean {
return (
value.startsWith("pmfa_") &&
value.length > "pmfa_".length &&
!value.startsWith("pmfa_ct_") &&
!value.startsWith("pmfa_pt_") &&
!value.startsWith("pmfa_ls_")
);
return ORGANIZATION_API_KEY_V1.test(value);
}

function isProjectToken(value: string): boolean {
return value.startsWith("pmfa_pt_") && value.length > "pmfa_pt_".length;
return PROJECT_TOKEN_V1.test(value);
}
15 changes: 11 additions & 4 deletions packages/typescript/test/bridge.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { ORGANIZATION_API_KEY, PROJECT_TOKEN } from "./support/credentials.js";
import { describe, expect, expectTypeOf, it, vi } from "vitest";

import {
Expand All @@ -21,7 +22,10 @@ describe("BridgeClient", () => {
Response.json(route),
);
const client = new BridgeClient({
credential: { type: "projectToken", value: "pmfa_pt_bridge" },
credential: {
type: "projectToken",
value: PROJECT_TOKEN,
},
baseUrl: "https://api.example.com",
fetch,
});
Expand All @@ -34,7 +38,7 @@ describe("BridgeClient", () => {
const [url, init] = fetch.mock.calls[0]!;
expect(new URL(String(url)).pathname).toBe("/v1/bridge/route");
expect(new Headers(init?.headers).get("authorization")).toBe(
"Bearer pmfa_pt_bridge",
`Bearer ${PROJECT_TOKEN}`,
);
});

Expand All @@ -57,7 +61,7 @@ describe("BridgeClient", () => {
new BridgeClient({
credential: {
type: "organizationApiKey",
value: "pmfa_organization",
value: ORGANIZATION_API_KEY,
},
fetch,
} as never),
Expand All @@ -69,7 +73,10 @@ describe("BridgeClient", () => {
expect(
() =>
new BridgeClient({
credential: { type: "projectToken", value: "pmfa_pt_bridge" },
credential: {
type: "projectToken",
value: PROJECT_TOKEN,
},
baseUrl: "http://api.example.com",
}),
).toThrow(PolymorfaConfigurationError);
Expand Down
6 changes: 5 additions & 1 deletion packages/typescript/test/business.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { ORGANIZATION_API_KEY } from "./support/credentials.js";
import { afterEach, describe, expect, expectTypeOf, it } from "vitest";

import {
Expand Down Expand Up @@ -35,7 +36,10 @@ async function businessServer(): Promise<{
return {
requests: server.requests,
client: new MessagingClient({
credential: { type: "apiKey", value: "pmfa_example" },
credential: {
type: "apiKey",
value: ORGANIZATION_API_KEY,
},
baseUrl: server.url,
maxNetworkRetries: 0,
}),
Expand Down
6 changes: 5 additions & 1 deletion packages/typescript/test/calls-lids-users.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { ORGANIZATION_API_KEY } from "./support/credentials.js";
import { afterEach, describe, expect, expectTypeOf, it } from "vitest";

import {
Expand Down Expand Up @@ -55,7 +56,10 @@ async function compactSurfaceServer(): Promise<{
return {
requests: server.requests,
client: new MessagingClient({
credential: { type: "apiKey", value: "pmfa_example" },
credential: {
type: "apiKey",
value: ORGANIZATION_API_KEY,
},
baseUrl: server.url,
maxNetworkRetries: 0,
}),
Expand Down
6 changes: 5 additions & 1 deletion packages/typescript/test/campaigns.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { ORGANIZATION_API_KEY } from "./support/credentials.js";
import { afterEach, describe, expect, expectTypeOf, it } from "vitest";

import {
Expand Down Expand Up @@ -89,7 +90,10 @@ async function campaignsServer(): Promise<{
return {
requests: server.requests,
client: new MessagingClient({
credential: { type: "apiKey", value: "pmfa_example" },
credential: {
type: "apiKey",
value: ORGANIZATION_API_KEY,
},
baseUrl: server.url,
maxNetworkRetries: 0,
}),
Expand Down
6 changes: 5 additions & 1 deletion packages/typescript/test/channels.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { ORGANIZATION_API_KEY } from "./support/credentials.js";
import { afterEach, describe, expect, expectTypeOf, it } from "vitest";

import {
Expand Down Expand Up @@ -44,7 +45,10 @@ async function channelsServer(): Promise<{
return {
requests: server.requests,
client: new MessagingClient({
credential: { type: "apiKey", value: "pmfa_example" },
credential: {
type: "apiKey",
value: ORGANIZATION_API_KEY,
},
baseUrl: server.url,
maxNetworkRetries: 0,
}),
Expand Down
68 changes: 64 additions & 4 deletions packages/typescript/test/client.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { ORGANIZATION_API_KEY, PROJECT_TOKEN } from "./support/credentials.js";
import { afterEach, describe, expect, expectTypeOf, it, vi } from "vitest";

import {
Expand Down Expand Up @@ -47,11 +48,17 @@ async function testClient(projectId?: string): Promise<{
projectId === undefined
? new Client({
...shared,
credential: { type: "organizationApiKey", value: "pmfa_org" },
credential: {
type: "organizationApiKey",
value: ORGANIZATION_API_KEY,
},
})
: new Client({
...shared,
credential: { type: "projectToken", value: "pmfa_pt_project" },
credential: {
type: "projectToken",
value: PROJECT_TOKEN,
},
projectId,
}),
};
Expand Down Expand Up @@ -89,7 +96,10 @@ describe("Client ownership", () => {
expect(
() =>
new (Client as unknown as new (options: unknown) => Client<"project">)({
credential: { type: "projectToken", value: "pmfa_pt_project" },
credential: {
type: "projectToken",
value: PROJECT_TOKEN,
},
projectId: undefined,
fetch,
}),
Expand All @@ -111,9 +121,59 @@ describe("Client ownership", () => {
expect(fetch).not.toHaveBeenCalled();
});

it("rejects malformed keys and special-purpose tickets before fetch", () => {
const fetch = vi.fn<typeof globalThis.fetch>();
const invalidOrganizationKeys = [
`pmfa_at_${"A".repeat(69)}`,
`pmfa_wst_${"A".repeat(68)}`,
`pmfa_sd_${"A".repeat(69)}`,
`pmfa_${"A".repeat(71)}`,
`pmfa_${"A".repeat(73)}`,
`pmfa_${"A".repeat(71)}+`,
];

for (const value of invalidOrganizationKeys) {
expect(
() =>
new Client({
credential: { type: "organizationApiKey", value },
fetch,
}),
).toThrow(PolymorfaConfigurationError);
expect(
() =>
new MessagingClient({
credential: { type: "apiKey", value },
fetch,
}),
).toThrow(PolymorfaConfigurationError);
}

for (const value of [
`pmfa_pt_${"A".repeat(93)}`,
`pmfa_pt_${"A".repeat(95)}`,
`pmfa_pt_${"A".repeat(93)}+`,
`pmfa_pt_${"A".repeat(93)}B`,
]) {
expect(
() =>
new Client({
credential: { type: "projectToken", value },
projectId: "project_1",
fetch,
}),
).toThrow(PolymorfaConfigurationError);
}

expect(fetch).not.toHaveBeenCalled();
});

it("treats an explicitly undefined organization projectId as unscoped", () => {
const client = new (Client as unknown as new (options: unknown) => Client)({
credential: { type: "organizationApiKey", value: "pmfa_org" },
credential: {
type: "organizationApiKey",
value: ORGANIZATION_API_KEY,
},
projectId: undefined,
});

Expand Down
10 changes: 7 additions & 3 deletions packages/typescript/test/coverage-reconciliation.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { ORGANIZATION_API_KEY } from "./support/credentials.js";
import { createHash } from "node:crypto";
import { readFileSync } from "node:fs";

Expand Down Expand Up @@ -129,7 +130,7 @@ describe("reconciled coverage evidence", () => {
Response.json(fixture.response, { status: fixture.status }),
);
const api = new HttpCallsApi({
apiKey: "pmfa_coverage",
apiKey: ORGANIZATION_API_KEY,
baseUrl: "https://api.example.com",
fetch,
});
Expand All @@ -153,7 +154,7 @@ describe("reconciled coverage evidence", () => {
expect(JSON.parse(init.body as string)).toEqual(fixture.body);
}
expect(new Headers(init.headers).get("authorization")).toBe(
"Bearer pmfa_coverage",
`Bearer ${ORGANIZATION_API_KEY}`,
);
if (fixture.method === "place") {
expect(new Headers(init.headers).get("idempotency-key")).toBe(
Expand All @@ -166,7 +167,10 @@ describe("reconciled coverage evidence", () => {
it("covers QuickLink settings through the exact management routes", async () => {
const fetch = vi.fn(async () => Response.json({ data: {} }));
const client = new Client({
credential: { type: "organizationApiKey", value: "pmfa_coverage" },
credential: {
type: "organizationApiKey",
value: ORGANIZATION_API_KEY,
},
fetch,
});
await client.quickLinkSettings.retrieve();
Expand Down
Loading