@q32/core 0.1.28 → 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 +1 -1
- package/dist/aws.d.ts +24 -0
- package/dist/aws.d.ts.map +1 -0
- package/dist/aws.js +109 -0
- package/dist/aws.js.map +1 -0
- package/dist/email.d.ts +8 -0
- package/dist/email.d.ts.map +1 -1
- package/dist/email.js +87 -0
- package/dist/email.js.map +1 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/ops-events.d.ts +4 -4
- package/dist/ops-events.d.ts.map +1 -1
- package/dist/ops-events.js +4 -4
- package/dist/ops-events.js.map +1 -1
- package/dist/ses-sns.d.ts +60 -0
- package/dist/ses-sns.d.ts.map +1 -0
- package/dist/ses-sns.js +96 -0
- package/dist/ses-sns.js.map +1 -0
- package/dist/ses.d.ts +53 -0
- package/dist/ses.d.ts.map +1 -0
- package/dist/ses.js +125 -0
- package/dist/ses.js.map +1 -0
- package/package.json +13 -1
- package/src/aws.ts +152 -0
- package/src/email.ts +99 -0
- package/src/index.ts +3 -0
- package/src/ops-events.ts +4 -4
- package/src/ses-sns.ts +144 -0
- package/src/ses.ts +180 -0
- package/docs/commonality-adgiro-relin-dirtsignal.md +0 -28
- package/docs/evaluation.md +0 -76
- package/docs/goal-audit.md +0 -28
package/src/ses-sns.ts
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
export type SnsEnvelope = {
|
|
2
|
+
Type?: string;
|
|
3
|
+
Message?: string;
|
|
4
|
+
MessageId?: string;
|
|
5
|
+
TopicArn?: string;
|
|
6
|
+
SubscribeURL?: string;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export type SesFeedbackMessage = {
|
|
10
|
+
eventType?: string;
|
|
11
|
+
notificationType?: string;
|
|
12
|
+
mail?: {
|
|
13
|
+
destination?: unknown;
|
|
14
|
+
};
|
|
15
|
+
bounce?: {
|
|
16
|
+
bouncedRecipients?: Array<{ emailAddress?: unknown }>;
|
|
17
|
+
};
|
|
18
|
+
complaint?: {
|
|
19
|
+
complainedRecipients?: Array<{ emailAddress?: unknown }>;
|
|
20
|
+
};
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export type SesFeedbackEvent = {
|
|
24
|
+
eventType: string;
|
|
25
|
+
emails: string[];
|
|
26
|
+
messageId?: string;
|
|
27
|
+
topicArn?: string;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export type SesReceiptMessage = {
|
|
31
|
+
mail?: {
|
|
32
|
+
messageId?: string;
|
|
33
|
+
source?: string;
|
|
34
|
+
commonHeaders?: {
|
|
35
|
+
subject?: string;
|
|
36
|
+
};
|
|
37
|
+
};
|
|
38
|
+
receipt?: {
|
|
39
|
+
recipients?: string[];
|
|
40
|
+
};
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export type SesReceiptEvent = {
|
|
44
|
+
messageId: string;
|
|
45
|
+
source?: string;
|
|
46
|
+
subject?: string;
|
|
47
|
+
recipients: string[];
|
|
48
|
+
snsMessageId?: string;
|
|
49
|
+
topicArn?: string;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export function parseSnsEnvelope(body: string): SnsEnvelope | null {
|
|
53
|
+
const parsed = parseJsonObject(body);
|
|
54
|
+
if (!parsed) return null;
|
|
55
|
+
return parsed as SnsEnvelope;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function isTrustedSnsSubscribeUrl(raw: string): boolean {
|
|
59
|
+
let url: URL;
|
|
60
|
+
try {
|
|
61
|
+
url = new URL(raw);
|
|
62
|
+
} catch {
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
return (
|
|
66
|
+
url.protocol === "https:" &&
|
|
67
|
+
(/^sns[.-][a-z0-9-]+\.amazonaws\.com$/i.test(url.hostname) ||
|
|
68
|
+
/^sns\.[a-z0-9-]+\.amazonaws\.com\.cn$/i.test(url.hostname))
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export async function confirmSnsSubscription(envelope: SnsEnvelope, fetcher: typeof fetch = fetch): Promise<boolean> {
|
|
73
|
+
if (envelope.Type !== "SubscriptionConfirmation" || typeof envelope.SubscribeURL !== "string") return false;
|
|
74
|
+
if (!isTrustedSnsSubscribeUrl(envelope.SubscribeURL)) return false;
|
|
75
|
+
const response = await fetcher(envelope.SubscribeURL, { method: "GET" });
|
|
76
|
+
return response.ok;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function parseSesFeedbackNotification(envelope: SnsEnvelope): SesFeedbackEvent | null {
|
|
80
|
+
if (envelope.Type !== "Notification" || typeof envelope.Message !== "string") return null;
|
|
81
|
+
const message = parseJsonObject(envelope.Message) as SesFeedbackMessage | null;
|
|
82
|
+
if (!message) return null;
|
|
83
|
+
const { eventType, emails } = extractSesFeedbackRecipients(message);
|
|
84
|
+
if (!eventType) return null;
|
|
85
|
+
return {
|
|
86
|
+
eventType,
|
|
87
|
+
emails,
|
|
88
|
+
...(envelope.MessageId ? { messageId: envelope.MessageId } : {}),
|
|
89
|
+
...(envelope.TopicArn ? { topicArn: envelope.TopicArn } : {}),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function extractSesFeedbackRecipients(message: SesFeedbackMessage): { eventType: string; emails: string[] } {
|
|
94
|
+
const eventType = String(message.eventType ?? message.notificationType ?? "");
|
|
95
|
+
const normalized = eventType.toLowerCase();
|
|
96
|
+
if (normalized === "bounce") {
|
|
97
|
+
const recipients = message.bounce?.bouncedRecipients?.map((recipient) => recipient.emailAddress) ?? [];
|
|
98
|
+
return { eventType, emails: uniqueEmails(recipients.length > 0 ? recipients : fallbackDestination(message)) };
|
|
99
|
+
}
|
|
100
|
+
if (normalized === "complaint") {
|
|
101
|
+
const recipients = message.complaint?.complainedRecipients?.map((recipient) => recipient.emailAddress) ?? [];
|
|
102
|
+
return { eventType, emails: uniqueEmails(recipients.length > 0 ? recipients : fallbackDestination(message)) };
|
|
103
|
+
}
|
|
104
|
+
return { eventType, emails: [] };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function parseSesReceiptNotification(envelope: SnsEnvelope): SesReceiptEvent | null {
|
|
108
|
+
if (envelope.Type !== "Notification" || typeof envelope.Message !== "string") return null;
|
|
109
|
+
const message = parseJsonObject(envelope.Message) as SesReceiptMessage | null;
|
|
110
|
+
const messageId = message?.mail?.messageId;
|
|
111
|
+
const recipients = message?.receipt?.recipients ?? [];
|
|
112
|
+
if (!messageId || recipients.length === 0) return null;
|
|
113
|
+
return {
|
|
114
|
+
messageId,
|
|
115
|
+
recipients,
|
|
116
|
+
...(message.mail?.source ? { source: message.mail.source } : {}),
|
|
117
|
+
...(message.mail?.commonHeaders?.subject ? { subject: message.mail.commonHeaders.subject } : {}),
|
|
118
|
+
...(envelope.MessageId ? { snsMessageId: envelope.MessageId } : {}),
|
|
119
|
+
...(envelope.TopicArn ? { topicArn: envelope.TopicArn } : {}),
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function parseJsonObject(value: string): Record<string, unknown> | null {
|
|
124
|
+
try {
|
|
125
|
+
const parsed = JSON.parse(value);
|
|
126
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed as Record<string, unknown> : null;
|
|
127
|
+
} catch {
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function fallbackDestination(message: SesFeedbackMessage): unknown[] {
|
|
133
|
+
return Array.isArray(message.mail?.destination) ? message.mail.destination : [];
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function uniqueEmails(values: Iterable<unknown>): string[] {
|
|
137
|
+
const emails = new Set<string>();
|
|
138
|
+
for (const value of values) {
|
|
139
|
+
if (typeof value !== "string") continue;
|
|
140
|
+
const email = value.trim().toLowerCase();
|
|
141
|
+
if (email.includes("@")) emails.add(email);
|
|
142
|
+
}
|
|
143
|
+
return [...emails];
|
|
144
|
+
}
|
package/src/ses.ts
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { awsFetch, createAwsConfigFromEnv, type AwsConfig, type AwsEnv } from "./aws.js";
|
|
2
|
+
import { buildRawMimeMessage, formatEmailAddress, type EmailAddress, type SendEmailInput } from "./email.js";
|
|
3
|
+
|
|
4
|
+
export type SesEmailInput = Omit<SendEmailInput, "from"> & {
|
|
5
|
+
from?: EmailAddress;
|
|
6
|
+
headers?: Record<string, string>;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export type SesSendResult = {
|
|
10
|
+
messageId: string;
|
|
11
|
+
provider: "ses";
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export type SesMailer = {
|
|
15
|
+
readonly enabled: boolean;
|
|
16
|
+
send(input: SesEmailInput): Promise<SesSendResult>;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export type SesMailerEnv = AwsEnv & {
|
|
20
|
+
FROM_EMAIL?: string;
|
|
21
|
+
EMAIL_FROM?: string;
|
|
22
|
+
NOTIFICATION_FROM_EMAIL?: string;
|
|
23
|
+
POSTMARK_FROM_EMAIL?: string;
|
|
24
|
+
SES_FROM_EMAIL?: string;
|
|
25
|
+
EMAIL_REPLY_TO?: string;
|
|
26
|
+
NOTIFICATION_REPLY_TO?: string;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export type SesSendOptions = {
|
|
30
|
+
fetcher?: typeof fetch;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export class NoopSesMailer implements SesMailer {
|
|
34
|
+
readonly enabled = false;
|
|
35
|
+
|
|
36
|
+
async send(): Promise<SesSendResult> {
|
|
37
|
+
throw new Error("SES mailer is not configured.");
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export class WorkerSesMailer implements SesMailer {
|
|
42
|
+
readonly enabled = true;
|
|
43
|
+
|
|
44
|
+
constructor(
|
|
45
|
+
private readonly config: AwsConfig,
|
|
46
|
+
private readonly defaults: {
|
|
47
|
+
from: EmailAddress;
|
|
48
|
+
replyTo?: EmailAddress[];
|
|
49
|
+
fetcher?: typeof fetch;
|
|
50
|
+
},
|
|
51
|
+
) {}
|
|
52
|
+
|
|
53
|
+
send(input: SesEmailInput): Promise<SesSendResult> {
|
|
54
|
+
return sendSesEmail(this.config, {
|
|
55
|
+
...input,
|
|
56
|
+
from: input.from ?? this.defaults.from,
|
|
57
|
+
replyTo: input.replyTo ?? this.defaults.replyTo,
|
|
58
|
+
}, { fetcher: this.defaults.fetcher });
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function createSesMailerFromEnv(
|
|
63
|
+
env: SesMailerEnv,
|
|
64
|
+
options: {
|
|
65
|
+
fromEmail?: string;
|
|
66
|
+
fromName?: string;
|
|
67
|
+
replyTo?: string | string[] | null;
|
|
68
|
+
fetcher?: typeof fetch;
|
|
69
|
+
} = {},
|
|
70
|
+
): SesMailer {
|
|
71
|
+
const config = createAwsConfigFromEnv(env);
|
|
72
|
+
const fromEmail = options.fromEmail ?? env.FROM_EMAIL ?? env.EMAIL_FROM ?? env.NOTIFICATION_FROM_EMAIL ?? env.POSTMARK_FROM_EMAIL ?? env.SES_FROM_EMAIL;
|
|
73
|
+
if (!config || !fromEmail) return new NoopSesMailer();
|
|
74
|
+
const replyTo = options.replyTo ?? env.NOTIFICATION_REPLY_TO ?? env.EMAIL_REPLY_TO;
|
|
75
|
+
return new WorkerSesMailer(config, {
|
|
76
|
+
from: { email: fromEmail, name: options.fromName },
|
|
77
|
+
replyTo: typeof replyTo === "string"
|
|
78
|
+
? [{ email: replyTo }]
|
|
79
|
+
: Array.isArray(replyTo)
|
|
80
|
+
? replyTo.map((email) => ({ email }))
|
|
81
|
+
: undefined,
|
|
82
|
+
fetcher: options.fetcher,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export async function sendSesEmail(
|
|
87
|
+
config: AwsConfig,
|
|
88
|
+
input: SendEmailInput & { headers?: Record<string, string> },
|
|
89
|
+
options: SesSendOptions = {},
|
|
90
|
+
): Promise<SesSendResult> {
|
|
91
|
+
const hasRawFeatures = Boolean(input.headers && Object.keys(input.headers).length > 0) || Boolean(input.attachments?.length);
|
|
92
|
+
const body = hasRawFeatures ? rawSesBody(input) : simpleSesBody(input);
|
|
93
|
+
const response = await awsFetch(config, `https://email.${config.region}.amazonaws.com/v2/email/outbound-emails`, {
|
|
94
|
+
method: "POST",
|
|
95
|
+
headers: { "content-type": "application/json" },
|
|
96
|
+
body: JSON.stringify(body),
|
|
97
|
+
}, { service: "ses", fetcher: options.fetcher });
|
|
98
|
+
const text = await response.text();
|
|
99
|
+
if (!response.ok) throw new Error(`SES send failed: ${response.status} ${text.slice(0, 300)}`);
|
|
100
|
+
const payload = parseJsonObject(text);
|
|
101
|
+
const messageId = stringValue(payload.MessageId) ?? stringValue(payload.messageId) ?? "";
|
|
102
|
+
return { provider: "ses", messageId };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export async function sesQuery(
|
|
106
|
+
config: AwsConfig,
|
|
107
|
+
params: Record<string, string>,
|
|
108
|
+
options: SesSendOptions = {},
|
|
109
|
+
): Promise<string> {
|
|
110
|
+
const body = new URLSearchParams({ Version: "2010-12-01", ...params }).toString();
|
|
111
|
+
const response = await awsFetch(config, `https://email.${config.region}.amazonaws.com/`, {
|
|
112
|
+
method: "POST",
|
|
113
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
114
|
+
body,
|
|
115
|
+
}, { service: "ses", fetcher: options.fetcher });
|
|
116
|
+
const text = await response.text();
|
|
117
|
+
if (!response.ok) throw new Error(`SES ${params.Action ?? "query"} failed: ${response.status} ${text.slice(0, 300)}`);
|
|
118
|
+
return text;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function inboundSesMxHost(region = "us-east-1"): string {
|
|
122
|
+
return `inbound-smtp.${region}.amazonaws.com`;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function simpleSesBody(input: SendEmailInput): Record<string, unknown> {
|
|
126
|
+
return {
|
|
127
|
+
FromEmailAddress: formatEmailAddress(input.from),
|
|
128
|
+
Destination: {
|
|
129
|
+
ToAddresses: input.to.map(formatEmailAddress),
|
|
130
|
+
...(input.cc?.length ? { CcAddresses: input.cc.map(formatEmailAddress) } : {}),
|
|
131
|
+
...(input.bcc?.length ? { BccAddresses: input.bcc.map(formatEmailAddress) } : {}),
|
|
132
|
+
},
|
|
133
|
+
...(input.replyTo?.length ? { ReplyToAddresses: input.replyTo.map(formatEmailAddress) } : {}),
|
|
134
|
+
Content: {
|
|
135
|
+
Simple: {
|
|
136
|
+
Subject: { Data: input.subject, Charset: "UTF-8" },
|
|
137
|
+
Body: {
|
|
138
|
+
...(input.text ? { Text: { Data: input.text, Charset: "UTF-8" } } : {}),
|
|
139
|
+
...(input.html ? { Html: { Data: input.html, Charset: "UTF-8" } } : {}),
|
|
140
|
+
},
|
|
141
|
+
},
|
|
142
|
+
},
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function rawSesBody(input: SendEmailInput & { headers?: Record<string, string> }): Record<string, unknown> {
|
|
147
|
+
return {
|
|
148
|
+
FromEmailAddress: formatEmailAddress(input.from),
|
|
149
|
+
Destination: {
|
|
150
|
+
ToAddresses: input.to.map(formatEmailAddress),
|
|
151
|
+
...(input.cc?.length ? { CcAddresses: input.cc.map(formatEmailAddress) } : {}),
|
|
152
|
+
...(input.bcc?.length ? { BccAddresses: input.bcc.map(formatEmailAddress) } : {}),
|
|
153
|
+
},
|
|
154
|
+
Content: {
|
|
155
|
+
Raw: {
|
|
156
|
+
Data: textToBase64(buildRawMimeMessage(input)),
|
|
157
|
+
},
|
|
158
|
+
},
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function textToBase64(value: string): string {
|
|
163
|
+
const bytes = new TextEncoder().encode(value);
|
|
164
|
+
let binary = "";
|
|
165
|
+
for (const byte of bytes) binary += String.fromCharCode(byte);
|
|
166
|
+
return btoa(binary);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function parseJsonObject(value: string): Record<string, unknown> {
|
|
170
|
+
try {
|
|
171
|
+
const parsed = JSON.parse(value);
|
|
172
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed as Record<string, unknown> : {};
|
|
173
|
+
} catch {
|
|
174
|
+
return {};
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function stringValue(value: unknown): string | null {
|
|
179
|
+
return typeof value === "string" ? value : null;
|
|
180
|
+
}
|
|
@@ -1,28 +0,0 @@
|
|
|
1
|
-
# Adgiro, Relin, and DirtSignal Commonality
|
|
2
|
-
|
|
3
|
-
This is the current direction for `@q32/core`: choose the best reusable pattern across the projects, then move the projects toward it.
|
|
4
|
-
|
|
5
|
-
## Chosen Defaults
|
|
6
|
-
|
|
7
|
-
- Postgres access should use Kysely over raw SQL once a project has long-lived relational read models. DirtSignal has the largest raw `postgres` surface today, but Relin's Kysely layer is the better long-term default because it makes schema ownership, joins, transactions, and refactors safer.
|
|
8
|
-
- Cloudflare Workers should create PG clients per request, queue invocation, cron tick, or script scope. Hyperdrive owns pooling in Worker runtime; Node scripts and tests should close clients explicitly.
|
|
9
|
-
- PG connection resolution should prefer Hyperdrive, then `PG_URL`, then an explicit test fallback only when the caller opts in.
|
|
10
|
-
- Non-local PG should use TLS by default. A provided `PG_CA_CERT` should be normalized and used for certificate verification.
|
|
11
|
-
- PG migrations should be explicit, status/dry-run capable, and separated from runtime credentials. Relin's admin/runtime split is the better default; DirtSignal's typed migration-array model is better than ad hoc SQL file parsing for application-owned schema modules.
|
|
12
|
-
- D1 remains the default for small control-plane state, but shared auth, jobs, and ops-event tables should be configurable D1 modules, not app-local forks.
|
|
13
|
-
|
|
14
|
-
## Core Modules To Grow
|
|
15
|
-
|
|
16
|
-
- `pg-kysely`: Kysely + `postgres.js` construction, Hyperdrive/PG_URL/CA-cert policy, and scoped `withKyselyPg`.
|
|
17
|
-
- `pg-migrations`: one migration runner that can consume SQL files or typed migration arrays, with status, dry-run, reset guard, and configurable migrations table.
|
|
18
|
-
- `d1-auth`: configurable users/orgs/memberships/identities/magic-link/session helpers. Apps keep policy and copy; core owns token hashing, one-time consume, identity upsert, and session persistence patterns.
|
|
19
|
-
- `mcp-oauth-d1`: the common OAuth client/code/token/device-flow repository used by Relin and DirtSignal, with app-provided principal lookup and entitlement policy.
|
|
20
|
-
- `d1-jobs`: Adgiro's richer job driver is closer to the target than the current minimal core job helper. Core should own enqueue policies, active locks, concurrency keys, delayed requeue, stale-running recovery, and linked ops events.
|
|
21
|
-
- `queue`: a typed Cloudflare Queue publisher/consumer adapter with inline mode for tests and local single-process execution.
|
|
22
|
-
- `ops-events`: converge on run IDs, event names, statuses/severity, target identifiers, metadata/error normalization, and best-effort writes.
|
|
23
|
-
|
|
24
|
-
## Project Movement
|
|
25
|
-
|
|
26
|
-
- Relin should keep using Kysely for PG and replace its local connection lifecycle with `@q32/core/pg-kysely`.
|
|
27
|
-
- DirtSignal should migrate new PG repositories to Kysely first, then wrap existing raw `postgres` repositories behind Kysely-compatible interfaces as they change.
|
|
28
|
-
- Adgiro should adopt the shared D1 job driver shape and ops-event schema before adding PG. If it adds PG, it should start on Kysely rather than raw `postgres`.
|
package/docs/evaluation.md
DELETED
|
@@ -1,76 +0,0 @@
|
|
|
1
|
-
# Suitability Evaluation
|
|
2
|
-
|
|
3
|
-
This package was seeded from repeated infrastructure found across Erik/Q32 TypeScript projects, including `adgiro`, `bizsnipe`, `dirtsignal`, `getflight`, `graphilize`, `ipogrid`, `logtura`, `onwardtravel`, `relin`, `zura`, and the `domains/*` apps.
|
|
4
|
-
|
|
5
|
-
## Replacement Matrix
|
|
6
|
-
|
|
7
|
-
| Common pattern | Current module | Suitable replacement scope |
|
|
8
|
-
| --- | --- | --- |
|
|
9
|
-
| AI JSON helper conventions | `ai` | Standardize model request/response contracts and JSON extraction around official provider SDK calls. |
|
|
10
|
-
| Typed environment helpers and app URL fallback | `env` | Replace local `getAppUrl`, required env, optional boolean, and binding guard helpers. |
|
|
11
|
-
| Cloudflare binding checks | `cloudflare` | Replace repeated `DB`, `R2`, and Queue binding guard code in Workers. |
|
|
12
|
-
| JSON responses, bearer/admin token checks, request body parsing | `http` | Replace repeated Worker/Hono-adjacent HTTP utility functions. |
|
|
13
|
-
| Prefixed IDs, random tokens, base64url, SHA-256 | `ids` | Replace local `createId`, token, digest-key, and hash helpers. |
|
|
14
|
-
| Signed session tokens and cookies | `session` | Replace small HMAC session implementations where full auth frameworks are unnecessary. |
|
|
15
|
-
| Auth/session/MCP authorization policy | `auth` | Keep principal lookup, session verification, admin checks, OAuth authorization-code exchange, token verification, and refresh rotation independent of Hono, React Router, or any single app. |
|
|
16
|
-
| Credential or provider-secret encryption | `crypto` | Replace local AES-GCM JSON encryption helpers backed by WebCrypto. |
|
|
17
|
-
| D1-like database typing and explicit migrations | `d1` | Replace duplicated `D1DatabaseLike` types and simple migration runners. |
|
|
18
|
-
| Postgres migration and JSON helpers | `pg` | Replace small pg migration scripts while still using official `pg` or `postgres` clients in apps. |
|
|
19
|
-
| Background job table behavior | `jobs` | Replace D1 job enqueue/claim/run/requeue/succeed/fail loops in small Worker apps. |
|
|
20
|
-
| Operator event recording and listing | `ops-events` | Replace repeated `ops_events` insert/list helpers. |
|
|
21
|
-
| D1 fixed-window rate limits | `rate-limit` | Replace lead, login, public API, and admin throttle tables. |
|
|
22
|
-
| R2 JSON payload/artifact storage | `r2-json` | Replace raw provider payload and generated artifact JSON storage helpers. |
|
|
23
|
-
| SEO/static output conventions | `seo` | Replace ad hoc sitemap, robots, canonical, Open Graph, and noindex tag helpers. |
|
|
24
|
-
| Email provider boundary | `email` | Standardize provider-independent send input/result shapes and address helpers. |
|
|
25
|
-
| Billing plan/status checks | `billing` | Replace local plan rank and active subscription status helpers around Stripe-backed apps. |
|
|
26
|
-
| Worker test helpers | `testing` | Replace small fake Queue/R2 helpers and JSON response assertions in unit tests; complements workerd/Miniflare integration tests. |
|
|
27
|
-
| OAuth/MCP metadata and storage | `oauth`, `mcp` | Replace repeated discovery metadata, API-to-tool descriptors, and configurable D1 OAuth client/code/token repositories. |
|
|
28
|
-
| API operation registries and discovery surfaces | `api` | Replace local operation registries, OpenAPI path generation, RFC 9727 API catalog linksets, Agent Skills indexes, and agent-discovery Link headers. |
|
|
29
|
-
| MCP manifests and bearer challenges | `mcp` | Replace repeated server-card metadata, plain-GET MCP manifests, API-operation tool descriptors, tool annotations, and OAuth protected-resource challenge headers. |
|
|
30
|
-
| Framework request adapters | `hono`, `react-router` | Translate Hono middleware and React Router loader/action requests into the same framework-neutral auth/API services. |
|
|
31
|
-
|
|
32
|
-
## Current Fit
|
|
33
|
-
|
|
34
|
-
The package is suitable for new Q32 Worker projects and for incremental replacement of local helpers in existing projects where the app-specific behavior is already separated from infrastructure primitives.
|
|
35
|
-
|
|
36
|
-
Strong first replacement candidates:
|
|
37
|
-
|
|
38
|
-
- D1-like interfaces duplicated in `relin` and `graphilize`.
|
|
39
|
-
- `ops_events` helpers in `onwardtravel`, `bizsnipe`, `logtura`, and `bce.email`.
|
|
40
|
-
- D1 jobs in `travelerideas`, `bizsnipe`, `logtura`, and smaller domain apps.
|
|
41
|
-
- signed session, ID, token, and base64url helpers in `zura`, `relin`, `getflight`, and `adgiro`.
|
|
42
|
-
- API operation metadata from `bce.email` and `relin`.
|
|
43
|
-
- OAuth/MCP metadata and auth/token flow from `getflight`, `captcha`, `ipogrid`, `relin`, and `bce.email`.
|
|
44
|
-
|
|
45
|
-
## Not Yet Complete
|
|
46
|
-
|
|
47
|
-
The package intentionally does not yet replace:
|
|
48
|
-
|
|
49
|
-
- complete app-specific login/account repositories
|
|
50
|
-
- Stripe billing repositories
|
|
51
|
-
- Postgres job runners and migration orchestration
|
|
52
|
-
- email provider clients
|
|
53
|
-
- Mantine app shells or SEO page components
|
|
54
|
-
- OpenAI/provider retry adapters beyond the shared request/result contracts
|
|
55
|
-
- Stripe webhook verification or Checkout/session creation, which should remain on the official Stripe SDK
|
|
56
|
-
- Mantine app shells or React components
|
|
57
|
-
|
|
58
|
-
Those should be added only after extracting one or two real migrations from existing apps so the shared API follows production usage rather than speculation.
|
|
59
|
-
|
|
60
|
-
## Verification
|
|
61
|
-
|
|
62
|
-
Current local gates:
|
|
63
|
-
|
|
64
|
-
- `pnpm typecheck`
|
|
65
|
-
- `pnpm test:coverage`
|
|
66
|
-
- `pnpm build`
|
|
67
|
-
- `pnpm pack --dry-run`
|
|
68
|
-
|
|
69
|
-
Coverage threshold is enforced in `vitest.config.ts` and CI runs the same gates.
|
|
70
|
-
|
|
71
|
-
Current coverage after the expanded common-module pass:
|
|
72
|
-
|
|
73
|
-
- Statements: 89.26%
|
|
74
|
-
- Branches: 75.25%
|
|
75
|
-
- Functions: 96.66%
|
|
76
|
-
- Lines: 95.15%
|
package/docs/goal-audit.md
DELETED
|
@@ -1,28 +0,0 @@
|
|
|
1
|
-
# Goal Audit
|
|
2
|
-
|
|
3
|
-
Objective: working Q32 core libraries, with the common libraries discussed in the project inventory, a public repo at the `q32llc` org, working CI/CD, strong types and good test coverage, evaluated as suitable to replace much common code in existing projects.
|
|
4
|
-
|
|
5
|
-
## Proven Complete
|
|
6
|
-
|
|
7
|
-
| Requirement | Evidence |
|
|
8
|
-
| --- | --- |
|
|
9
|
-
| Public repo under `q32llc` | `https://github.com/q32llc/q32-core`, public, default branch `main`. |
|
|
10
|
-
| Working local package | `pnpm build` succeeds and `pnpm pack --dry-run` produces a clean tarball. |
|
|
11
|
-
| Strong TypeScript types | `pnpm typecheck` succeeds under `strict` TypeScript with generated declarations. |
|
|
12
|
-
| Good test coverage | `pnpm test:coverage` succeeds with enforced thresholds; latest local coverage is 89.26% statements, 75.25% branches, 96.66% functions, 95.15% lines. |
|
|
13
|
-
| Working CI | GitHub Actions CI passes on `main` and runs install, typecheck, coverage, build, and pack dry run. |
|
|
14
|
-
| Common code surfaces | Modules exist for `api`, `ai`, `billing`, `cloudflare`, `crypto`, `d1`, `email`, `env`, `http`, `ids`, `jobs`, `mcp`, `oauth`, `ops-events`, `pg`, `r2-json`, `rate-limit`, `seo`, `session`, `testing`, and `time`. |
|
|
15
|
-
| Suitability evaluation | `docs/evaluation.md` maps modules to common patterns found in existing projects and identifies first replacement candidates. |
|
|
16
|
-
| Consumer install smoke | Packed tarball was installed into a clean temp project and imported successfully from `@q32/core`. |
|
|
17
|
-
|
|
18
|
-
## Incomplete Or Externally Blocked
|
|
19
|
-
|
|
20
|
-
| Requirement | Status |
|
|
21
|
-
| --- | --- |
|
|
22
|
-
| npm package published | Complete for `@q32/core@0.1.1`; npm reports `0.1.1` as the latest published version. |
|
|
23
|
-
| npm publish step in CD | Trusted publishing is configured for `q32llc/q32-core` and `.github/workflows/release.yml`; verify on the next GitHub release. |
|
|
24
|
-
| Proven replacement in existing apps | Not complete. The library is evaluated as suitable, but no existing app has been migrated to consume it yet. |
|
|
25
|
-
|
|
26
|
-
## Recommended Next Proof Step
|
|
27
|
-
|
|
28
|
-
Migrate one low-risk repeated slice in an existing app, such as replacing the duplicated `D1DatabaseLike` type in `relin` or `graphilize`, or replacing `ops_events` helpers in `onwardtravel`. That will validate the public API against production code and expose any ergonomics issues before broad adoption.
|