|
| 1 | +// --------------------------------------------------------------------------- |
| 2 | +// Whether the selected organization has moved to Executor v2. |
| 3 | +// |
| 4 | +// When a user finishes importing a v1 organization, v2 writes two keys onto |
| 5 | +// the v1 WorkOS organization's metadata: |
| 6 | +// executor_v2_org_slug the v2 organization's slug (its `/org/<slug>` URL) |
| 7 | +// executor_v2_moved_at when the import finished, as an ISO 8601 timestamp |
| 8 | +// Both must be present and well formed; anything else, and every failure to |
| 9 | +// read the organization, answers "not moved". The answer only drives a notice: |
| 10 | +// a moved organization keeps working in v1. |
| 11 | +// --------------------------------------------------------------------------- |
| 12 | + |
| 13 | +import { Effect } from "effect"; |
| 14 | + |
| 15 | +import { WorkOSClient } from "./workos"; |
| 16 | + |
| 17 | +export const V2_MOVED_SLUG_KEY = "executor_v2_org_slug"; |
| 18 | +export const V2_MOVED_AT_KEY = "executor_v2_moved_at"; |
| 19 | + |
| 20 | +/** v2's organization slug rule (`OrganizationSlug` in v2's hosted contracts). */ |
| 21 | +const V2_SLUG = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; |
| 22 | +const V2_SLUG_MAX_LENGTH = 45; |
| 23 | + |
| 24 | +/** An ISO 8601 date and time with an explicit offset, as `Date#toISOString` |
| 25 | + * writes it. A bare date or a local time is ambiguous, so it is refused. */ |
| 26 | +const ISO_TIMESTAMP = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:\d{2})$/; |
| 27 | + |
| 28 | +export interface V2Moved { |
| 29 | + /** The v2 organization's page: `<v2 origin>/org/<slug>`. */ |
| 30 | + readonly v2Url: string; |
| 31 | + readonly v2Slug: string; |
| 32 | + /** When the import finished, normalized to `Date#toISOString`. */ |
| 33 | + readonly movedAt: string; |
| 34 | +} |
| 35 | + |
| 36 | +/** The move recorded in an organization's metadata, or `null` when there is |
| 37 | + * none or it is malformed. */ |
| 38 | +export const parseV2Moved = ( |
| 39 | + metadata: Readonly<Record<string, unknown>>, |
| 40 | + v2Origin: URL, |
| 41 | +): V2Moved | null => { |
| 42 | + const slug = metadata[V2_MOVED_SLUG_KEY]; |
| 43 | + const movedAt = metadata[V2_MOVED_AT_KEY]; |
| 44 | + if (typeof slug !== "string" || slug.length > V2_SLUG_MAX_LENGTH || !V2_SLUG.test(slug)) { |
| 45 | + return null; |
| 46 | + } |
| 47 | + if (typeof movedAt !== "string" || !ISO_TIMESTAMP.test(movedAt)) return null; |
| 48 | + const time = Date.parse(movedAt); |
| 49 | + if (!Number.isFinite(time)) return null; |
| 50 | + return { |
| 51 | + v2Url: new URL(`/org/${slug}`, v2Origin).href, |
| 52 | + v2Slug: slug, |
| 53 | + movedAt: new Date(time).toISOString(), |
| 54 | + }; |
| 55 | +}; |
| 56 | + |
| 57 | +/** The read the check makes. */ |
| 58 | +export interface V2MovedSource { |
| 59 | + readonly organizationMetadata: ( |
| 60 | + organizationId: string, |
| 61 | + ) => Effect.Effect<Readonly<Record<string, unknown>>, unknown>; |
| 62 | +} |
| 63 | + |
| 64 | +/** Whether the organization has moved to v2. Never fails: an unavailable |
| 65 | + * read answers `null`, so a WorkOS outage shows no notice. */ |
| 66 | +export const v2Moved = ( |
| 67 | + source: V2MovedSource, |
| 68 | + organizationId: string, |
| 69 | + v2Origin: URL, |
| 70 | +): Effect.Effect<V2Moved | null> => |
| 71 | + source.organizationMetadata(organizationId).pipe( |
| 72 | + Effect.map((metadata) => parseV2Moved(metadata, v2Origin)), |
| 73 | + Effect.orElseSucceed(() => null), |
| 74 | + Effect.withSpan("v2Moved.read"), |
| 75 | + ); |
| 76 | + |
| 77 | +/** The organization's metadata, read live from WorkOS (where v2 writes it). */ |
| 78 | +export const liveV2MovedSource = Effect.gen(function* () { |
| 79 | + const workos = yield* WorkOSClient; |
| 80 | + return { |
| 81 | + organizationMetadata: (organizationId) => |
| 82 | + workos |
| 83 | + .getOrganization(organizationId) |
| 84 | + .pipe(Effect.map((organization) => organization.metadata)), |
| 85 | + } satisfies V2MovedSource; |
| 86 | +}); |
0 commit comments