Skip to content

Commit 4306eed

Browse files
author
Rajat
committed
Replace Provisioning Secret With Organization Key Auth; Fixed org api
key pane;
1 parent cb400a3 commit 4306eed

21 files changed

Lines changed: 7540 additions & 84 deletions

File tree

‎.env.example‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,6 @@ EMAIL_FROM=
3232
# Optional integrations and operational settings.
3333
AUTH_COOKIE_DOMAIN=
3434
ENABLE_TRUST_PROXY=false
35-
PROVISIONING_SECRET=
3635
MEDIALIT_APIKEY=
3736
MEDIALIT_SERVER=https://api.medialit.cloud
3837
MAX_UPLOAD_SIZE=10485760

‎.github/workflows/publish-packages.yaml‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,8 @@ jobs:
2727

2828
- name: Configure CI Git User
2929
run: |
30-
git config --global user.name 'SendLit'
31-
git config --global user.email 'hi@sendlit.dev'
30+
git config --global user.name 'CodeLit'
31+
git config --global user.email 'hi@codelit.dev'
3232
git remote set-url origin https://x-access-token:${{ secrets.PAT }}@github.com/${{ github.repository }}
3333
env:
3434
GITHUB_PAT: ${{ secrets.PAT }}

‎ARCHITECTURE.md‎

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -220,20 +220,15 @@ Phase 5 — **done**:
220220
team its owning account belongs to would defeat the point of scoping keys
221221
to one team.
222222
- `src/provisioning/routes.ts`: `POST /provisioning/teams`, a separate,
223-
static-secret-guarded (`PROVISIONING_SECRET`, compared with
224-
`crypto.timingSafeEqual`), server-to-server endpoint for multi-tenant
223+
organization-key-authenticated server-to-server endpoint for multi-tenant
225224
consumers (the motivating case: CourseLit provisioning one SendLit team
226225
per one of its own tenants/"domains") to find-or-create a team at any
227226
point after both stacks have booted — not just at container start.
228227
Idempotent, keyed by a consumer-supplied `externalId` (e.g.
229228
`courselit:<domainId>`) rather than the owner's email, since two of a
230229
consumer's own tenants may share an owner email (which would otherwise
231-
incorrectly merge them into one team). Ownership is still assigned: the
232-
request body includes `ownerEmail`, which is resolved via
233-
`findOrCreateBareAccount` (email lowercased; account created if missing);
234-
that account becomes the team's `ownerAccountId` and its sole
235-
`team_members` row with role `owner`. There is no fixed platform/system
236-
owner account — whichever email the consumer sends is the owner.
230+
incorrectly merge them into one team). Provisioning creates no account
231+
or membership; the organization owns the resulting team.
237232
- `src/bootstrap.ts`: a _separate_, boot-time-only convenience directly
238233
ported from MediaLit's `createAdminUser()` — if `SUPER_ADMIN_EMAIL` is
239234
set and no account exists for it yet, creates one (with its default team

‎apps/api/.env.example‎

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -62,12 +62,6 @@ EMAIL_FROM=
6262
# src/bootstrap.ts.
6363
SUPER_ADMIN_EMAIL=
6464

65-
# Required to use POST /provisioning/teams — the server-to-server endpoint a
66-
# multi-tenant consumer (e.g. CourseLit) uses to provision one SendLit team
67-
# per one of its own tenants. Generate a long random value and configure it
68-
# identically on both sides. See src/provisioning/routes.ts.
69-
PROVISIONING_SECRET=
70-
7165
# Optional: PostHog error tracking, product analytics and log shipping.
7266
# When POSTHOG_API_KEY is set, all pino logs (src/services/log.ts) at
7367
# POSTHOG_LOG_LEVEL and above are also shipped to PostHog via OTLP, and

‎apps/api/README.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -186,8 +186,8 @@ exactly one team.
186186
187187
`POST /provisioning/teams` is a separate server-to-server endpoint for
188188
multi-tenant consumers, such as CourseLit, to find or create one SendLit team
189-
per external tenant. It is guarded by `X-Sendlit-Provisioning-Secret`, not by
190-
OAuth or API key authentication.
189+
per external tenant. It requires a scoped organization API key as a Bearer
190+
token; it is not authenticated by an end-user OAuth session or team API key.
191191
192192
`SUPER_ADMIN_EMAIL` is only a boot-time convenience for the first local or
193193
self-hosted account. It is not the provisioning mechanism for multi-tenant

‎apps/api/docs/organizations.md‎

Lines changed: 11 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -49,11 +49,9 @@ team per school. FrontLit has one organization and one team per FrontLit team;
4949
those teams own their ESPs. When CourseLit later enables BYO ESPs, a school
5050
adds a team-owned ESP without changing teams or moving data.
5151

52-
Organization API keys replace the deployment-wide provisioning secret. An
53-
organization key identifies exactly one organization and provisions teams only
54-
inside it. The existing `PROVISIONING_SECRET`,
55-
`X-Sendlit-Provisioning-Secret`, and `ownerEmail` provisioning behavior are
56-
removed, not deprecated.
52+
Organization API keys identify exactly one organization and provision teams
53+
only inside it. The former deployment-wide provisioning mechanism and
54+
`ownerEmail` provisioning behavior are removed, not deprecated.
5755

5856
There are no production users. The database may be reset. Implementation must
5957
therefore rewrite the Drizzle schema and migrations as a clean baseline rather
@@ -280,7 +278,8 @@ provisioning:
280278
- `esp_configs.teamId` requires every ESP to be copied into one team.
281279
- A platform using one provider for many tenants must duplicate credentials and
282280
rotate each copy.
283-
- The global `PROVISIONING_SECRET` cannot identify or isolate an integration.
281+
- A deployment-wide shared credential cannot identify or isolate an
282+
integration.
284283
- Provisioning accepts `ownerEmail`, creates a SendLit identity, and grants that
285284
email owner membership.
286285
- A school administrator can consequently gain SendLit access merely because
@@ -343,7 +342,7 @@ ESP grant = which team may use organization infrastructure
343342
## Non-goals
344343

345344
- Preserving local development data.
346-
- Supporting a legacy `PROVISIONING_SECRET` compatibility window.
345+
- Supporting a legacy deployment-wide credential compatibility window.
347346
- Migrating production records; there are no production users.
348347
- Allowing cross-organization ESP grants.
349348
- Letting a team administer an organization ESP.
@@ -1657,8 +1656,7 @@ Authorization: Bearer sl_org_live_...
16571656

16581657
Remove:
16591658

1660-
- `PROVISIONING_SECRET`;
1661-
- `X-Sendlit-Provisioning-Secret`;
1659+
- the deployment-wide provisioning credential;
16621660
- constant-time comparison against an environment secret;
16631661
- `ownerEmail`;
16641662
- `findOrCreateBareAccount` from provisioning;
@@ -2197,7 +2195,7 @@ content, or unredacted webhook credentials.
21972195
- Generate all REST/OpenAPI routes from the ts-rest contract.
21982196
- Add an `OrganizationApiKey` security scheme.
21992197
- Map `/provisioning/*` to organization-key security.
2200-
- Remove `X-Sendlit-Provisioning-Secret` documentation entirely.
2198+
- Remove documentation for the deployment-wide provisioning credential.
22012199
- Do not expose internal UUIDs or organization ESP identifiers on team
22022200
surfaces.
22032201
- Model provisioning create responses as the `created: true | false`
@@ -2340,8 +2338,7 @@ Delete from code, environment examples, Docker Compose, deployment config,
23402338
tests, and docs:
23412339

23422340
```text
2343-
PROVISIONING_SECRET
2344-
X-Sendlit-Provisioning-Secret
2341+
deployment-wide provisioning credential
23452342
```
23462343

23472344
No replacement deployment-global provisioning secret is introduced.
@@ -2708,8 +2705,8 @@ The market-ready organization feature is complete when:
27082705
default change.
27092706
10. Organization keys are scoped, hashed, revocable, rotatable, auditable, and
27102707
restricted to one organization.
2711-
11. `PROVISIONING_SECRET`, `X-Sendlit-Provisioning-Secret`, and `ownerEmail`
2712-
provisioning are completely removed.
2708+
11. The deployment-wide provisioning credential and `ownerEmail` provisioning
2709+
are completely removed.
27132710
12. Provisioning is idempotent within organization scope and atomically creates
27142711
policy/settings/grant/team key.
27152712
13. Provisioning creates no human identity or membership.

‎apps/api/docs/platform-customer.md‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -63,9 +63,9 @@ SendLit's current tenant model correctly uses one team per external workspace,
6363
but the current sending and provisioning assumptions cannot safely support
6464
managed embedding at scale:
6565

66-
- `POST /provisioning/teams` is protected by one deployment-wide
67-
`PROVISIONING_SECRET`. It cannot identify which platform customer made the
68-
request or restrict that caller to its own teams.
66+
- `POST /provisioning/teams` used a deployment-wide shared credential. That
67+
model cannot identify which platform customer made the request or restrict
68+
that caller to its own teams.
6969
- `teams.externalId` is globally unique. Two platform customers may legitimately
7070
use the same external identifier.
7171
- User-managed ESP credentials live on each team. Copying one platform ESP
@@ -1389,7 +1389,7 @@ backfill. It requires a migration report and platform-by-platform confirmation.
13891389

13901390
During a time-bounded compatibility window:
13911391

1392-
- `X-Sendlit-Provisioning-Secret` maps to the legacy platform customer;
1392+
- the legacy shared credential maps to the legacy platform customer;
13931393
- `/provisioning/teams` retains its current response contract;
13941394
- the compatibility schema may continue accepting `ownerEmail`, but the value
13951395
is ignored/deprecated and never creates an account or membership;
Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
# Team provisioning
2+
3+
This document describes the provisioning behavior implemented today and the
4+
product boundary it creates for embedding products such as FrontLit. It is a
5+
short orientation for maintainers; the executable contract remains
6+
`packages/api-contract/src/contract.ts`, and the implementation remains
7+
`apps/api/src/provisioning/routes.ts`.
8+
9+
## Mental model
10+
11+
- An **organization** is the administrative and provisioning boundary. It owns
12+
teams, organization API keys, organization ESPs, grants, policies, and
13+
organization-level audit events.
14+
- A **team** is the email-data boundary. Contacts, segments, templates,
15+
broadcasts, sequences, transactional email, team ESPs, settings, and team
16+
API keys belong to one team.
17+
- Every team has one required `organizationId`. A team cannot belong to or be
18+
shared by multiple organizations. There is currently no public team-transfer
19+
API.
20+
- Human organization membership and human team membership are independent.
21+
Organization membership does not grant access to team data, and team
22+
membership does not grant organization administration.
23+
- Organization keys and team keys are different principals. An organization
24+
key provisions and manages resources within one organization according to
25+
its scopes. A team key accesses one fixed team's product APIs.
26+
27+
## What provisioning does
28+
29+
Provisioning is a server-to-server API for a multi-tenant product to create
30+
one SendLit team per external tenant inside an existing SendLit organization.
31+
The organization is derived exclusively from the organization API key; it
32+
cannot be selected in the request.
33+
34+
```http
35+
POST /provisioning/teams
36+
Authorization: Bearer sl_org_live_...
37+
Content-Type: application/json
38+
39+
{
40+
"externalId": "fl_team_...",
41+
"name": "Acme",
42+
"sender": {
43+
"fromName": "Acme",
44+
"replyTo": "hello@acme.example"
45+
},
46+
"mailingAddress": "...",
47+
"delivery": {
48+
"useOrganizationDefault": false,
49+
"teamEspEnabled": true,
50+
"teamCanChangeDefault": true
51+
},
52+
"quota": {
53+
"dailyLimit": 500,
54+
"monthlyLimit": 10000
55+
}
56+
}
57+
```
58+
59+
The call requires an organization key with `teams:provision`. It creates the
60+
team and its general settings, delivery settings, optional organization ESP
61+
grant/quota, and initial team API key. The organization always comes from the
62+
authenticated key, never from `organizationId` in a body or header.
63+
64+
Provisioning does **not**:
65+
66+
- create an organization;
67+
- create a Better Auth user/account;
68+
- interpret an email address as a SendLit identity;
69+
- create organization or team membership; or
70+
- give the organization key implicit access to the team's contacts or other
71+
team data.
72+
73+
There is no deployment-wide provisioning secret or special provisioning
74+
header. The former global-secret mechanism has been removed; do not
75+
reintroduce it in code, configuration, OpenAPI, or consumer integrations.
76+
77+
## Idempotency and key handling
78+
79+
Idempotency is scoped by `(organizationId, externalId)`. Consequently, two
80+
organizations may use the same external ID, but one organization cannot create
81+
two provisioned teams with the same external ID.
82+
83+
The implementation stores a SHA-256 hash of `JSON.stringify(body)` as the
84+
creation-request hash:
85+
86+
- the first request creates the team and returns `created: true` with the only
87+
plaintext copy of the initial team API key;
88+
- an identical replay returns the existing team with `created: false` and
89+
`apiKey: null`;
90+
- a replay for the same external ID with a different creation payload returns
91+
`409 provisioning_conflict` and does not mutate the team; intentional
92+
changes must use the lifecycle `PATCH` endpoint; and
93+
- concurrent identical requests converge on one team through the database
94+
uniqueness constraint and race recovery in the query layer.
95+
96+
Consumers must persist the initial key immediately and encrypted at rest.
97+
SendLit stores only its hash and cannot recover the plaintext. If the key is
98+
lost, `POST /provisioning/teams/:teamId/keys` creates a replacement and revokes
99+
the active keys previously created by organization-key provisioning.
100+
101+
## Lifecycle API and scopes
102+
103+
All provisioning lifecycle routes resolve the team inside the organization
104+
identified by the caller's key. A team from another organization is not
105+
addressable through that key.
106+
107+
| Operation | Required scope | Behavior |
108+
| ------------------------------------------ | ----------------- | ----------------------------------------------------------------- |
109+
| `POST /provisioning/teams` | `teams:provision` | Idempotently create a team and its initial key |
110+
| `GET /provisioning/teams/:teamId` | `teams:read` | Read the provisioned team view |
111+
| `PATCH /provisioning/teams/:teamId` | `teams:manage` | Update name, sender, mailing address, delivery controls, or quota |
112+
| `POST /provisioning/teams/:teamId/keys` | `teams:keys` | Replace organization-created integration keys |
113+
| `POST /provisioning/teams/:teamId/suspend` | `teams:manage` | Stop new sends |
114+
| `POST /provisioning/teams/:teamId/resume` | `teams:manage` | Resume new sends |
115+
| `DELETE /provisioning/teams/:teamId` | `teams:manage` | Soft-archive the team |
116+
| `GET /provisioning/teams/:teamId/usage` | `usage:read` | Read daily and monthly quota windows |
117+
118+
Provision, update, key rotation, suspension, resumption, and archival are
119+
written to the organization audit log. The `/provisioning` router is currently
120+
rate-limited to 30 requests per minute per source IP.
121+
122+
## FrontLit integration
123+
124+
FrontLit uses one dedicated SendLit organization and stores a scoped
125+
organization key as `SENDLIT_ORGANIZATION_API_KEY`. Every FrontLit team is
126+
eagerly mapped 1:1 to a SendLit team using the FrontLit team's public ID as
127+
`externalId`.
128+
129+
```text
130+
FrontLit SendLit organization
131+
├── FrontLit team A -> SendLit team A -> contacts, newsletters, email data
132+
└── FrontLit team B -> SendLit team B -> contacts, newsletters, email data
133+
```
134+
135+
FrontLit encrypts the returned team API key in its per-team `integrations`
136+
record and uses it for all subsequent calls. Browsers never receive this key.
137+
FrontLit is a thin proxy for newsletter functionality, while SendLit remains
138+
the system of record for contacts and email data. Therefore, exposing the same
139+
underlying team in SendLit's own UI requires no contact migration or copying.
140+
141+
FrontLit currently carries `ownerEmail` in its local provisioning retry job,
142+
but it does not send that value in the SendLit provisioning request. It has no
143+
identity or membership effect in SendLit.
144+
145+
## Direct SendLit access for an embedded customer
146+
147+
This user-facing handoff is **not implemented today**. Signing up for SendLit
148+
with the same email used in FrontLit does not grant access to the provisioned
149+
team. A new SendLit user receives a personal default organization, while the
150+
FrontLit-provisioned team still has no human membership.
151+
152+
Do not solve this by adding the customer to FrontLit's SendLit organization.
153+
Any organization member can enumerate that organization's teams, which would
154+
expose other FrontLit customer workspaces. The intended boundary is direct
155+
membership in only the relevant team.
156+
157+
The recommended future flow is:
158+
159+
1. An authenticated FrontLit user selects **Open in SendLit**.
160+
2. FrontLit requests a short-lived, single-use invitation/handoff for the
161+
exact provisioned team using its organization credential.
162+
3. SendLit authenticates the person, preferably through **Continue with
163+
FrontLit** SSO or otherwise through a verified email flow.
164+
4. Accepting the handoff creates an `admin` (or explicitly chosen) membership
165+
in that team only. It creates no organization membership.
166+
5. SendLit selects that team and opens its dashboard. Existing contacts,
167+
broadcasts, and settings are immediately present because it is the same
168+
team, not an imported copy.
169+
170+
The handoff must be bound to the exact team and intended identity, expire
171+
quickly, be single-use, and be audited. Same-email string matching alone is
172+
not sufficient authorization, particularly for shared addresses such as
173+
`hi@example.com`. FrontLit must never expose the stored team API key to achieve
174+
this flow.
175+
176+
Useful API work for this flow would include team invitation creation and
177+
acceptance, team-member listing/revocation, and possibly a dedicated
178+
least-privilege organization-key scope for membership management. The schema
179+
already supports team membership, but the public team invitation/claim
180+
workflow does not yet exist.
181+
182+
## Ownership decision
183+
184+
The current architecture supports an **embedded/add-on** model: FrontLit owns
185+
the SendLit organization, and its customer receives access to one or more
186+
specific teams. This is the smallest and safest path to offering SendLit's
187+
advanced sequence and transactional-email UI over the customer's existing
188+
newsletter data.
189+
190+
It does not yet support converting that workspace into an independently owned
191+
SendLit organization. If customers must retain the workspace after leaving
192+
FrontLit, the product needs an explicit, audited team-transfer capability or a
193+
different model in which each customer organization is created independently
194+
from the beginning. Provisioning alone cannot provide that ownership change.
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
ALTER TABLE "jwks" ADD COLUMN "alg" text;--> statement-breakpoint
2+
ALTER TABLE "jwks" ADD COLUMN "crv" text;

0 commit comments

Comments
 (0)