Move docs/ package mirror into document-navigator plugin
Relocate the ~6k-file docs tree from the repo root into plugins/document-navigator/docs/ so the plugin is self-contained and portable. docs/superpowers/ (specs and plans) stays at the repo root. Moved via cp+rm rather than git mv because directory rename() fails with EPERM on this WSL /mnt DrvFs mount; git rename-detection pairs the identical-content files at commit time. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,87 @@
|
||||
# AWS SNS — `@suaveplan/sms-providers`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Real AWS SNS SMS provider implementing `SmsProvider` from `@suaveplan/sms`. POSTs `Action=Publish` form bodies to the regional SNS endpoint (`https://sns.<region>.amazonaws.com/`) signed with inline AWS Signature V4 (see `./sigv4`); SMS-specific message attributes (`AWS.SNS.SMS.SMSType` and `AWS.SNS.SMS.SenderID`) are forwarded when supplied on the provider config. Inbound SNS HTTP/S notifications — subscription confirmation, delivery callbacks, unsubscribe confirmation — are validated through `verifyNotification` which delegates the RSA-SHA1 verification step to an injectable callback so this package never imports `node:crypto`.
|
||||
|
||||
The class holds no in-flight state and routes every HTTP call through the injectable `FetchLike` seam. Tests stand up a `node:http` server on a random port and inject a `fetch` adapter that points at it.
|
||||
|
||||
## Features
|
||||
|
||||
- Inline AWS Signature V4 signing (no `@aws-sdk` dep tree).
|
||||
- `AWS.SNS.SMS.SMSType` defaults to `Transactional`; override via `config.smsType: "Promotional"`.
|
||||
- `AWS.SNS.SMS.SenderID` forwarded from the message `from` or the config `senderId`.
|
||||
- Optional `sessionToken` for STS-issued credentials (`X-Amz-Security-Token`).
|
||||
- Status mapping covers `pending` / `success` / `failure` / `optout`.
|
||||
- `verifyNotification(payload, rsaVerify)` validates the documented SNS signing chain — fetches the signing cert URL through the injectable fetch, builds the canonical string-to-sign (different field order for `Notification` vs `SubscriptionConfirmation`/`UnsubscribeConfirmation`), and delegates the RSA verification to the supplied callback.
|
||||
- `parseInbound` discriminates between delivery-receipt notifications (Subject = `"AWS SMS Delivery Receipt"`) and inbound mobile-originated SMS payloads; recognises CTIA opt-out keywords on the inbound body.
|
||||
- OTel — counter + histogram on every send + verify.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { AwsSnsProvider } from "@suaveplan/sms-providers";
|
||||
|
||||
const sns = new AwsSnsProvider({
|
||||
region: "us-east-1",
|
||||
accessKeyId: "AKIA…",
|
||||
secretAccessKey: "…",
|
||||
senderId: "SUAVEPLAN",
|
||||
});
|
||||
const r = await sns.send({ to: "+15551234567", body: "Hello" });
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { AwsSnsProvider } from "@suaveplan/sms-providers";
|
||||
import { createVerify } from "node:crypto";
|
||||
|
||||
const sns = new AwsSnsProvider({
|
||||
region: "us-east-1",
|
||||
accessKeyId: "AKIA…",
|
||||
secretAccessKey: "…",
|
||||
sessionToken: "FwoGZXIvYXdz…",
|
||||
smsType: "Transactional",
|
||||
});
|
||||
|
||||
// Node-side RSA-SHA1 verifier callback.
|
||||
const rsaVerify = async (pem: string, msg: string, sigB64: string) =>
|
||||
createVerify("RSA-SHA1").update(msg).end().verify(pem, sigB64, "base64");
|
||||
|
||||
const ok = await sns.verifyNotification(
|
||||
/* payload */ {
|
||||
Type: "Notification",
|
||||
MessageId: "abc",
|
||||
TopicArn: "arn:aws:sns:…",
|
||||
Subject: "AWS SMS Delivery Receipt",
|
||||
Message: JSON.stringify({ status: "SUCCESS" }),
|
||||
Timestamp: new Date().toISOString(),
|
||||
Signature: "<base64>",
|
||||
SignatureVersion: "1",
|
||||
SigningCertURL:
|
||||
"https://sns.us-east-1.amazonaws.com/SimpleNotificationService-XXX.pem",
|
||||
},
|
||||
rsaVerify
|
||||
);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Symbol | Kind | Notes |
|
||||
|---|---|---|
|
||||
| `AwsSnsProvider` | class | Implements `SmsProvider`. |
|
||||
| `send(message, options?)` | method | SigV4-signed POST `Action=Publish`. |
|
||||
| `getStatus(id)` | method | Returns a synthetic `queued` report (SNS does not expose per-message status). |
|
||||
| `mapStatus(raw)` | method | Canonical mapping. |
|
||||
| `verifyNotification(payload, rsaVerify)` | method | Delegates RSA-SHA1 verification to the supplied callback. |
|
||||
| `parseInbound(payload)` | method | Discriminates into `InboundSmsEvent`. |
|
||||
| `buildSnsStringToSign(payload)` | helper | Exported for advanced use. |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`getStatus` deliberately returns a synthetic `queued` report rather than synthesise a fake `delivered` — SNS reports SMS lifecycle exclusively via the configured delivery-status SNS topic (typically wired into CloudWatch Logs in production), and lying about provider behaviour would mislead consumers. The recommended production pipeline is to subscribe to the delivery-status topic, route the notifications back through the provider's `parseInbound`, and feed the resulting `delivery_receipt` events to `@suaveplan/sms`'s `DeliveryTracker`. The `senderId` config overrides the per-message `from`; this matches the AWS console UX where the account-level Sender ID is the production default.
|
||||
@@ -0,0 +1,80 @@
|
||||
# SigV4 — `@suaveplan/sms-providers`
|
||||
|
||||
## Purpose
|
||||
|
||||
Inline AWS Signature V4 signer used by `AwsSnsProvider`. Replaces the `@aws-sdk/signature-v4` dependency tree (15+ MB) with an under-80-LOC implementation of the canonical-request → string-to-sign → signing-key → final-signature chain. HMAC computations are delegated to the injectable `HmacSign` seam from `@suaveplan/sms-providers`, so the signer stays universal and tests can substitute a deterministic adapter.
|
||||
|
||||
The signer returns a fresh headers object containing the input headers plus `Host`, `X-Amz-Date`, `X-Amz-Content-Sha256`, the optional `X-Amz-Security-Token` (when a session token is supplied), and the `Authorization` header in the documented form `AWS4-HMAC-SHA256 Credential=…, SignedHeaders=…, Signature=<64 hex>`.
|
||||
|
||||
## Features
|
||||
|
||||
- 64-character lowercase-hex signature per the AWS spec.
|
||||
- Canonical request built from method + path + canonical headers (lower-cased keys, trimmed values) + signed-headers list + body-sha256 hex.
|
||||
- `dateStamp` (`YYYYMMDD`) and `amzDate` (`YYYYMMDDTHHMMSSZ`) derived from a caller-supplied `Date` so tests can pin the clock and produce stable signatures.
|
||||
- Signing key chain: `kDate → kRegion → kService → kSigning` via HMAC-SHA256 over `AWS4<secret>`, `region`, `service`, `aws4_request`.
|
||||
- Body SHA-256 hashing routed through `globalThis.crypto.subtle.digest("SHA-256", …)` (universal: Bun, browsers, Workers, Deno, Node 19+).
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { defaultHmac } from "@suaveplan/sms-providers";
|
||||
import { signRequest, sha256OfUtf8 } from "@suaveplan/sms-providers/aws-sns/sigv4";
|
||||
|
||||
const body = "Action=Publish&Message=hi";
|
||||
const out = await signRequest({
|
||||
accessKeyId: "AKIA…",
|
||||
secretAccessKey: "…",
|
||||
region: "us-east-1",
|
||||
service: "sns",
|
||||
method: "POST",
|
||||
host: "sns.us-east-1.amazonaws.com",
|
||||
path: "/",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
bodySha256Hex: await sha256OfUtf8(body),
|
||||
now: new Date(),
|
||||
hmac: defaultHmac,
|
||||
});
|
||||
// out.headers.Authorization === "AWS4-HMAC-SHA256 Credential=AKIA…/<dateStamp>/us-east-1/sns/aws4_request, SignedHeaders=…, Signature=<64 hex>"
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { signRequest, sha256OfUtf8 } from "@suaveplan/sms-providers/aws-sns/sigv4";
|
||||
import { createHmac } from "node:crypto";
|
||||
|
||||
// Drive the signer with a Node-side HMAC adapter and a pinned date so
|
||||
// the resulting Authorization header is byte-stable between test runs.
|
||||
const hmac = async (algo: "SHA-1" | "SHA-256", key: Uint8Array, msg: Uint8Array) =>
|
||||
new Uint8Array(
|
||||
createHmac(algo === "SHA-1" ? "sha1" : "sha256", Buffer.from(key))
|
||||
.update(Buffer.from(msg))
|
||||
.digest()
|
||||
);
|
||||
const out = await signRequest({
|
||||
accessKeyId: "AKIDEXAMPLE",
|
||||
secretAccessKey: "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY",
|
||||
region: "us-east-1",
|
||||
service: "sns",
|
||||
method: "POST",
|
||||
host: "sns.us-east-1.amazonaws.com",
|
||||
path: "/",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
bodySha256Hex: await sha256OfUtf8("Action=Publish"),
|
||||
now: new Date("2026-04-24T10:00:00Z"),
|
||||
hmac,
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Symbol | Kind | Notes |
|
||||
|---|---|---|
|
||||
| `signRequest(input)` | function | Returns `{ headers }` with the SigV4 signature applied. |
|
||||
| `sha256OfUtf8(s)` | function | SHA-256-of-UTF-8 helper returning lowercase hex. |
|
||||
| `SignRequestInput` | interface | Required inputs to the signer. |
|
||||
| `SignRequestOutput` | interface | Returned headers object. |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The canonical-request newline before the body-sha256 is intentional per the AWS docs (the canonical request ends with `\n<body-sha256>`, no trailing newline). The signed-headers list is computed by lower-casing every input key, sorting, and joining with `;` — this matches the canonical-request header rendering exactly.
|
||||
Reference in New Issue
Block a user