Press n or j to go to the next uncovered block, b, p or k for the previous block.
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 | 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 27x 27x 1x 7x 7x 7x 1x 1x 13x 13x 13x 13x 13x 7x 13x 1x 2x 2x 2x 1x 1x 1x 1x 1x 1x 1x 1x 11x 11x 11x 11x 5x 5x 5x 6x 6x 6x 6x 6x 6x 6x 6x 6x 6x 6x 6x 11x 11x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x | import { RestError } from "@azure/data-tables";
import type { Tier, UserEntity } from "../models/user";
import { ensureTable, nowIso } from "./tablesService";
/**
* Users table access (data-model.md): PK "User", RK lowercased email.
* Replaces AllowedUsers as the withAuth point-read — auto-created on first
* sign-in (self-serve signup), read uncached so tier flips (webhook/CLI) are
* effective on the very next request (SC-004, mirrors 002's revocation
* property).
*/
const TABLE = "Users";
const PARTITION = "User";
export function normalizeEmail(email: string): string {
return email.trim().toLowerCase();
}
function isNotFound(err: unknown): boolean {
return err instanceof RestError && err.statusCode === 404;
}
function isConflict(err: unknown): boolean {
return err instanceof RestError && err.statusCode === 409;
}
async function getEntityOrNull(rowKey: string): Promise<UserEntity | null> {
const client = await ensureTable(TABLE);
try {
return await client.getEntity<UserEntity>(PARTITION, rowKey);
} catch (err) {
if (isNotFound(err)) return null;
throw err;
}
}
export async function getByEmail(email: string): Promise<UserEntity | null> {
return getEntityOrNull(normalizeEmail(email));
}
/**
* Self-serve signup: returns the existing row, or auto-creates
* {sub, tier: "free", createdAt} on first sign-in. A migrated row that has
* never signed in (no recorded sub) has its sub filled in on this call —
* the first-seen sub is authoritative, mirroring allowedUsersStore's
* recordSignIn semantics.
*/
export async function getOrCreate(email: string, sub: string): Promise<UserEntity> {
const rowKey = normalizeEmail(email);
const existing = await getEntityOrNull(rowKey);
if (existing) {
if (!existing.sub) {
const client = await ensureTable(TABLE);
await client.updateEntity(
{ partitionKey: PARTITION, rowKey, sub },
"Merge"
);
return { ...existing, sub };
}
return existing;
}
const entity: UserEntity = {
partitionKey: PARTITION,
rowKey,
sub,
tier: "free",
createdAt: nowIso(),
};
const client = await ensureTable(TABLE);
try {
await client.createEntity(entity);
return entity;
} catch (err) {
if (isConflict(err)) {
// Create race: another concurrent sign-in won — read its row.
const row = await getEntityOrNull(rowKey);
if (row) return row;
}
throw err;
}
}
/** Admin override (CLI): flips the entitlement tier. */
export async function setTier(email: string, tier: Tier): Promise<void> {
const client = await ensureTable(TABLE);
await client.updateEntity(
{ partitionKey: PARTITION, rowKey: normalizeEmail(email), tier },
"Merge"
);
}
/** Admin override (CLI): block/unblock — 403 in withAuth when true. */
export async function setBlocked(email: string, blocked: boolean): Promise<void> {
const client = await ensureTable(TABLE);
await client.updateEntity(
{ partitionKey: PARTITION, rowKey: normalizeEmail(email), blocked },
"Merge"
);
}
/**
* Single Merge write applying webhook-derived subscription state (tier,
* Paddle identifiers, display fields, the paddleEventOccurredAt stale
* guard) — contracts/paddle-webhook.md. The row must already exist (users
* are created at first sign-in / migration); callers resolve the user
* before calling this.
*/
export async function applySubscriptionState(
email: string,
patch: Partial<Omit<UserEntity, "partitionKey" | "rowKey">>
): Promise<void> {
const client = await ensureTable(TABLE);
await client.updateEntity(
{ partitionKey: PARTITION, rowKey: normalizeEmail(email), ...patch },
"Merge"
);
}
/** Webhook fallback resolution path: match by stored paddleCustomerId. */
export async function findByPaddleCustomerId(
customerId: string
): Promise<UserEntity | null> {
const client = await ensureTable(TABLE);
const rows = client.listEntities<UserEntity>({
queryOptions: {
filter: `PartitionKey eq '${PARTITION}' and paddleCustomerId eq '${customerId.replace(/'/g, "''")}'`,
},
});
for await (const row of rows) {
return row;
}
return null;
}
/** All user rows, for the admin CLI's `list` command. */
export async function listUsers(): Promise<UserEntity[]> {
const client = await ensureTable(TABLE);
const rows = client.listEntities<UserEntity>({
queryOptions: { filter: `PartitionKey eq '${PARTITION}'` },
});
const users: UserEntity[] = [];
for await (const row of rows) users.push(row);
return users;
}
|