@moloodev/platform 0.0.0-stage → 0.1.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/src/naming.ts ADDED
@@ -0,0 +1,128 @@
1
+ type CopySeparator = "-" | "_";
2
+
3
+ function escapeRegExp(value: string): string {
4
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
5
+ }
6
+
7
+ const COMPOUND_EXTENSIONS = [
8
+ ".layout.json",
9
+ ".loop.json",
10
+ ".table.json",
11
+ ".catalog.json",
12
+ ".bundle.json",
13
+ ".tar.gz",
14
+ ".tar.bz2",
15
+ ".tar.xz",
16
+ ".tar.zst",
17
+ ] as const;
18
+
19
+ const SINGLE_EXTENSION = /^(.+)(\.(?=[A-Za-z0-9]{1,10}$)[0-9]*[A-Za-z][A-Za-z0-9]*)$/;
20
+
21
+ /**
22
+ * Split a file name into stem and extension, recognizing compound extensions
23
+ * (`.layout.json`, `.tar.gz`, …) before single extensions (`.md`, `.png`, …).
24
+ * Dotfiles (`.keep`, `.env`) and version-like tails (`v1.0`) have `extension: ""`.
25
+ */
26
+ export function splitFileName(name: string): {
27
+ stem: string;
28
+ extension: string;
29
+ } {
30
+ const lower = name.toLowerCase();
31
+ for (const compound of COMPOUND_EXTENSIONS) {
32
+ if (lower.endsWith(compound) && name.length > compound.length) {
33
+ const stem = name.slice(0, name.length - compound.length);
34
+ if (stem && stem !== ".") {
35
+ return {
36
+ stem,
37
+ extension: name.slice(name.length - compound.length),
38
+ };
39
+ }
40
+ }
41
+ }
42
+ const match = SINGLE_EXTENSION.exec(name);
43
+ if (!match) return { stem: name, extension: "" };
44
+ return { stem: match[1]!, extension: match[2]! };
45
+ }
46
+
47
+ /**
48
+ * Split an uploaded file name into single stem and extension (`notes.md` →
49
+ * `notes` + `.md`, `archive.tar.gz` → `archive.tar` + `.gz`).
50
+ */
51
+ export function splitFileExtension(name: string): {
52
+ stem: string;
53
+ extension: string;
54
+ } {
55
+ const match = SINGLE_EXTENSION.exec(name);
56
+ if (!match) return { stem: name, extension: "" };
57
+ return { stem: match[1]!, extension: match[2]! };
58
+ }
59
+
60
+ /**
61
+ * Next numbered copy name among siblings: `{stem}{separator}2{extension}`,
62
+ * `{stem}{separator}3{extension}`, …
63
+ * Bare `{stem}{extension}` counts as 1; gaps ignored (uses max). Separator defaults to "-".
64
+ */
65
+ export function nextNumberedCopyName(
66
+ stem: string,
67
+ siblingNames: readonly string[],
68
+ opts?: { bareWhenFree?: true; separator?: CopySeparator; extension?: string },
69
+ ): string {
70
+ const separator = opts?.separator ?? "-";
71
+ const extension = opts?.extension ?? "";
72
+ const pattern = new RegExp(
73
+ `^${escapeRegExp(stem)}(?:${escapeRegExp(separator)}(\\d+))?${escapeRegExp(extension)}$`,
74
+ "i",
75
+ );
76
+ let max = 0;
77
+ let bareTaken = false;
78
+ for (const name of siblingNames) {
79
+ const match = pattern.exec(name);
80
+ if (!match) continue;
81
+ if (!match[1]) bareTaken = true;
82
+ const n = match[1] ? Number(match[1]) : 1;
83
+ if (Number.isFinite(n) && n > max) max = n;
84
+ }
85
+ if (opts?.bareWhenFree && !bareTaken) return `${stem}${extension}`;
86
+ if (max === 0) return `${stem}${separator}2${extension}`;
87
+ return `${stem}${separator}${max + 1}${extension}`;
88
+ }
89
+
90
+ /**
91
+ * Strip one trailing `{separator}{n}` from the stem of `sourceName` (preserving
92
+ * any file extension) so `coin-spin-2.png` shares the `coin-spin.png` series.
93
+ */
94
+ export function duplicateSeriesStem(
95
+ sourceName: string,
96
+ separator: CopySeparator = "-",
97
+ ): string {
98
+ const { stem, extension } = splitFileName(sourceName);
99
+ const match = new RegExp(`^(.*)${escapeRegExp(separator)}(\\d+)$`).exec(stem);
100
+ if (!match || !match[1]) return sourceName;
101
+ return `${match[1]}${extension}`;
102
+ }
103
+
104
+ /**
105
+ * Next duplicate asset/file name: `{stem}-2{extension}`, `{stem}-3{extension}`, …
106
+ */
107
+ export function nextDuplicateName(
108
+ sourceName: string,
109
+ siblingNames: readonly string[],
110
+ ): string {
111
+ const { stem, extension } = splitFileName(duplicateSeriesStem(sourceName));
112
+ return nextNumberedCopyName(stem, siblingNames, { extension });
113
+ }
114
+
115
+ /**
116
+ * Allocate a free name for create: prefer bare `{stem}{extension}`, then
117
+ * `{stem}-2{extension}`, `{stem}-3{extension}`, …
118
+ */
119
+ export function allocateCreateName(
120
+ desiredName: string,
121
+ siblingNames: readonly string[],
122
+ ): string {
123
+ const { stem, extension } = splitFileName(desiredName);
124
+ return nextNumberedCopyName(stem, siblingNames, {
125
+ bareWhenFree: true,
126
+ extension,
127
+ });
128
+ }
package/src/network.ts ADDED
@@ -0,0 +1,88 @@
1
+ const NETWORK_LIMIT = 20;
2
+ const RESERVED_SUFFIXES = ["moloo.dev", "moloo.sh"];
3
+
4
+ function invalid(detail: string): never {
5
+ throw new Error(`Invalid editor manifest: ${detail}`);
6
+ }
7
+
8
+ /** A trailing DNS dot still names the host, so the block check ignores it. */
9
+ function policyHost(hostname: string): string {
10
+ let host = hostname;
11
+ while (host.endsWith(".")) host = host.slice(0, -1);
12
+ return host;
13
+ }
14
+
15
+ function reservedHost(hostname: string): string | undefined {
16
+ const host = policyHost(hostname);
17
+ const suffix = RESERVED_SUFFIXES.find(
18
+ (item) => host === item || host.endsWith(`.${item}`),
19
+ );
20
+ return suffix === undefined ? undefined : host;
21
+ }
22
+
23
+ function requireOrigin(value: unknown, index: number): string {
24
+ const field = `network.${index}`;
25
+ if (typeof value !== "string") {
26
+ invalid(`${field} must be an https origin`);
27
+ }
28
+ if (/^http:\/\//i.test(value)) {
29
+ invalid(`${field} must not use http`);
30
+ }
31
+ if (value.includes("*")) {
32
+ invalid(`${field} must not use a wildcard`);
33
+ }
34
+ if (/[A-Z]/.test(value)) {
35
+ invalid(`${field} must be lowercase`);
36
+ }
37
+ if (value.includes("@")) {
38
+ invalid(`${field} must not include userinfo`);
39
+ }
40
+ if (value.includes("?")) {
41
+ invalid(`${field} must not include a query`);
42
+ }
43
+ if (value.includes("#")) {
44
+ invalid(`${field} must not include a fragment`);
45
+ }
46
+ if (value.startsWith("https://") && value.slice("https://".length).includes("/")) {
47
+ invalid(`${field} must not include a path`);
48
+ }
49
+
50
+ let url: URL;
51
+ try {
52
+ url = new URL(value);
53
+ } catch {
54
+ invalid(`${field} must be an https origin (https://host[:port])`);
55
+ }
56
+ if (url.protocol !== "https:") {
57
+ invalid(`${field} must use https`);
58
+ }
59
+ if (value !== url.origin) {
60
+ invalid(`${field} must be an https origin (https://host[:port])`);
61
+ }
62
+ const blocked = reservedHost(url.hostname);
63
+ if (blocked !== undefined) {
64
+ invalid(`${field} must not target ${blocked}`);
65
+ }
66
+ return value;
67
+ }
68
+
69
+ /**
70
+ * HTTPS origins a manifest may declare.
71
+ * Missing means none. Entries are returned unchanged.
72
+ */
73
+ export function parseNetwork(input: unknown): string[] {
74
+ if (input === undefined) return [];
75
+ if (!Array.isArray(input)) invalid("network must be an array");
76
+ if (input.length > NETWORK_LIMIT) {
77
+ invalid(`network accepts at most ${NETWORK_LIMIT} origins`);
78
+ }
79
+ const seen = new Set<string>();
80
+ const origins: string[] = [];
81
+ for (const [index, value] of input.entries()) {
82
+ const origin = requireOrigin(value, index);
83
+ if (seen.has(origin)) invalid(`network.${index} duplicates ${origin}`);
84
+ seen.add(origin);
85
+ origins.push(origin);
86
+ }
87
+ return origins;
88
+ }
@@ -0,0 +1,85 @@
1
+ export type OccupancyClientMessage =
2
+ | { type: "join"; assetId: string; tabId?: string }
3
+ | { type: "leave"; assetId: string };
4
+
5
+ type OccupancyServerMessage = {
6
+ type: "snapshot";
7
+ editors: Record<string, string[]>;
8
+ };
9
+
10
+ function isNonEmptyString(value: unknown): value is string {
11
+ return typeof value === "string" && value.length > 0;
12
+ }
13
+
14
+ export function parseOccupancyClient(
15
+ raw: string,
16
+ ): OccupancyClientMessage | null {
17
+ let parsed: unknown;
18
+ try {
19
+ parsed = JSON.parse(raw);
20
+ } catch {
21
+ return null;
22
+ }
23
+ if (!parsed || typeof parsed !== "object") return null;
24
+ const rec = parsed as Record<string, unknown>;
25
+ if (
26
+ (rec.type === "join" || rec.type === "leave") &&
27
+ isNonEmptyString(rec.assetId)
28
+ ) {
29
+ if (rec.type === "join" && isNonEmptyString(rec.tabId)) {
30
+ return { type: "join", assetId: rec.assetId, tabId: rec.tabId };
31
+ }
32
+ return { type: rec.type, assetId: rec.assetId };
33
+ }
34
+ return null;
35
+ }
36
+
37
+ export function parseOccupancyServer(
38
+ raw: string,
39
+ ): OccupancyServerMessage | null {
40
+ let parsed: unknown;
41
+ try {
42
+ parsed = JSON.parse(raw);
43
+ } catch {
44
+ return null;
45
+ }
46
+ if (!parsed || typeof parsed !== "object") return null;
47
+ const rec = parsed as Record<string, unknown>;
48
+ if (rec.type !== "snapshot" || !rec.editors || typeof rec.editors !== "object") {
49
+ return null;
50
+ }
51
+ const editors: Record<string, string[]> = {};
52
+ for (const [assetId, userIds] of Object.entries(
53
+ rec.editors as Record<string, unknown>,
54
+ )) {
55
+ if (!Array.isArray(userIds) || !userIds.every((id) => typeof id === "string")) {
56
+ return null;
57
+ }
58
+ editors[assetId] = userIds;
59
+ }
60
+ return { type: "snapshot", editors };
61
+ }
62
+
63
+ export function stringifyOccupancyClient(message: OccupancyClientMessage): string {
64
+ return JSON.stringify(message);
65
+ }
66
+
67
+ export function stringifyOccupancyServer(message: OccupancyServerMessage): string {
68
+ return JSON.stringify(message);
69
+ }
70
+
71
+ export function occupancyCanSend(ready: boolean): boolean {
72
+ return ready;
73
+ }
74
+
75
+ export function applyOccupancyIntent(
76
+ wanted: string | null,
77
+ action: OccupancyClientMessage,
78
+ ): { wanted: string | null; send: OccupancyClientMessage | null } {
79
+ if (action.type === "join") {
80
+ if (wanted === action.assetId) return { wanted, send: null };
81
+ return { wanted: action.assetId, send: action };
82
+ }
83
+ if (wanted !== action.assetId) return { wanted, send: null };
84
+ return { wanted: null, send: action };
85
+ }
package/src/origins.ts ADDED
@@ -0,0 +1,45 @@
1
+ import { IDENTITY_ISSUER, MARKETING_SITE, resolveSiteOrigin } from "./platform";
2
+ import { resolveSiteEnv, type SiteEnv } from "./url";
3
+ import { getWorkspaceHostUrl } from "./workspace-host";
4
+
5
+ /** Marketing apex (`apps/web`). */
6
+ export function getMarketingOrigin(
7
+ env: SiteEnv = resolveSiteEnv(),
8
+ ): string {
9
+ return resolveSiteOrigin(MARKETING_SITE, env);
10
+ }
11
+
12
+ /** Identity issuer (`apps/id`). Production is `https://id.moloo.dev`; otherwise the catalog local origin. */
13
+ export function getIdOrigin(env: SiteEnv = resolveSiteEnv()): string {
14
+ return resolveSiteOrigin(IDENTITY_ISSUER, env);
15
+ }
16
+
17
+ /** Account settings on the identity issuer. */
18
+ export function getIdAccountUrl(
19
+ env: SiteEnv = resolveSiteEnv(),
20
+ ): string {
21
+ return `${getIdOrigin(env)}/account/profile`;
22
+ }
23
+
24
+ /** Sign-in page on the identity issuer. */
25
+ export function getIdSignInUrl(
26
+ env: SiteEnv = resolveSiteEnv(),
27
+ ): string {
28
+ return `${getIdOrigin(env)}/sign-in`;
29
+ }
30
+
31
+ /** Workspace on the app host (`https://app.moloo.dev/w/{slug}`). */
32
+ export function getWorkspaceUrl(
33
+ slug: string,
34
+ env: SiteEnv = resolveSiteEnv(),
35
+ ): string {
36
+ return `${getWorkspaceHostUrl(env)}/w/${slug}`;
37
+ }
38
+
39
+ /** Invite accept page on the app host. The invitation id selects the workspace. */
40
+ export function getInviteUrl(
41
+ invitationId: string,
42
+ env: SiteEnv = resolveSiteEnv(),
43
+ ): string {
44
+ return `${getWorkspaceHostUrl(env)}/invite/${invitationId}`;
45
+ }
@@ -0,0 +1,115 @@
1
+ import type { AppIcon } from "./app-icon";
2
+ import { resolveSiteEnv, type SiteEnv } from "./url";
3
+
4
+ export type PlatformAppId = "api" | "status" | "admin";
5
+
6
+ export type SiteOriginRef = {
7
+ localPort: number;
8
+ productionHost: string;
9
+ };
10
+
11
+ export type PlatformApp = SiteOriginRef & {
12
+ id: PlatformAppId;
13
+ name: string;
14
+ tagline: string;
15
+ icon: AppIcon;
16
+ color: string;
17
+ /** Path appended to the origin for chrome entry. */
18
+ entryPath?: string;
19
+ /** Hide from chrome unless the session user has the admin role. */
20
+ requiresAdmin?: boolean;
21
+ /**
22
+ * When false, omit from platform chrome lists.
23
+ * The app remains resolvable by id (e.g. user-menu deep links).
24
+ */
25
+ showInSwitcher?: boolean;
26
+ };
27
+
28
+ /** Marketing apex (`apps/web`) — not shown in the site switcher. */
29
+ export const MARKETING_SITE: SiteOriginRef = {
30
+ localPort: 3000,
31
+ productionHost: "moloo.dev",
32
+ };
33
+
34
+ /** Identity issuer (`apps/id`) — not shown in the site switcher. */
35
+ export const IDENTITY_ISSUER: SiteOriginRef = {
36
+ localPort: 3002,
37
+ productionHost: "id.moloo.dev",
38
+ };
39
+
40
+ /**
41
+ * Infrastructure apps shown only via explicit links (not the site switcher).
42
+ */
43
+ export const PLATFORM_APPS: readonly PlatformApp[] = [
44
+ {
45
+ id: "api",
46
+ name: "API",
47
+ tagline: "게이트웨이",
48
+ icon: "code",
49
+ color: "#34d399",
50
+ localPort: 3004,
51
+ productionHost: "api.moloo.dev",
52
+ showInSwitcher: false,
53
+ },
54
+ {
55
+ id: "status",
56
+ name: "Status",
57
+ tagline: "상태",
58
+ icon: "activity",
59
+ color: "#fb7185",
60
+ localPort: 3005,
61
+ productionHost: "status.moloo.dev",
62
+ showInSwitcher: false,
63
+ },
64
+ {
65
+ id: "admin",
66
+ name: "Admin",
67
+ tagline: "운영",
68
+ icon: "shield",
69
+ color: "#a78bfa",
70
+ localPort: 3006,
71
+ productionHost: "admin.moloo.dev",
72
+ requiresAdmin: true,
73
+ showInSwitcher: false,
74
+ },
75
+ ];
76
+
77
+ /** Dev server port from the site catalog. */
78
+ export function resolveDevPort(site: Pick<SiteOriginRef, "localPort">): number {
79
+ return site.localPort;
80
+ }
81
+
82
+ function localHost(productionHost: string): string {
83
+ return productionHost === "moloo.dev"
84
+ ? "local.moloo.dev"
85
+ : productionHost.replace(".moloo.dev", ".local.moloo.dev");
86
+ }
87
+
88
+ /**
89
+ * `NODE_ENV=production` uses the production host.
90
+ * Every other value uses the local host and the catalog port.
91
+ */
92
+ export function resolveSiteOrigin(
93
+ site: SiteOriginRef,
94
+ env: SiteEnv = resolveSiteEnv(),
95
+ ): string {
96
+ return env === "production"
97
+ ? `https://${site.productionHost}`
98
+ : `https://${localHost(site.productionHost)}:${site.localPort}`;
99
+ }
100
+
101
+ export function getPlatformAppById(
102
+ id: string,
103
+ ): PlatformApp | undefined {
104
+ return PLATFORM_APPS.find((app) => app.id === id);
105
+ }
106
+
107
+ export function getPlatformAppUrl(
108
+ app: Pick<PlatformApp, "localPort" | "productionHost" | "entryPath">,
109
+ env: SiteEnv = resolveSiteEnv(),
110
+ ): string {
111
+ const origin = resolveSiteOrigin(app, env);
112
+ const path = app.entryPath?.trim();
113
+ if (!path) return origin;
114
+ return `${origin}${path.startsWith("/") ? path : `/${path}`}`;
115
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * VS Code identifier: `${publisher}.${name}`.
3
+ * Shared by editor ids and asset kind ids.
4
+ * @see https://code.visualstudio.com/api/references/extension-manifest
5
+ */
6
+ const QUALIFIED_NAME = /^([a-z0-9A-Z][a-z0-9-A-Z]*)\.([a-z0-9A-Z][a-z0-9-A-Z]*)$/;
7
+
8
+ export type QualifiedName = {
9
+ publisher: string;
10
+ name: string;
11
+ };
12
+
13
+ export function parseQualifiedName(id: string): QualifiedName | null {
14
+ const match = QUALIFIED_NAME.exec(id);
15
+ if (!match) return null;
16
+ return { publisher: match[1]!, name: match[2]! };
17
+ }
18
+
19
+ export function isQualifiedName(id: string): boolean {
20
+ return QUALIFIED_NAME.test(id);
21
+ }
@@ -0,0 +1,35 @@
1
+ import {
2
+ IDENTITY_ISSUER,
3
+ MARKETING_SITE,
4
+ PLATFORM_APPS,
5
+ resolveSiteOrigin,
6
+ } from "./platform";
7
+ import type { SiteEnv } from "./url";
8
+ import { WORKSPACE_HOST } from "./workspace-host";
9
+
10
+ export function matchTrustedOrigin(
11
+ origin: string | null | undefined,
12
+ origins: readonly string[],
13
+ ): origin is string {
14
+ if (!origin) return false;
15
+ return origins.includes(origin);
16
+ }
17
+
18
+ export function isTrustedBrowserOrigin(
19
+ origin: string | null | undefined,
20
+ env: SiteEnv,
21
+ ): boolean {
22
+ if (!origin) return false;
23
+ let url: URL;
24
+ try {
25
+ url = new URL(origin);
26
+ } catch {
27
+ return false;
28
+ }
29
+ if (url.origin !== origin || url.protocol !== "https:" || url.hostname.endsWith(".")) {
30
+ return false;
31
+ }
32
+ const admin = PLATFORM_APPS.find((app) => app.id === "admin");
33
+ return [MARKETING_SITE, IDENTITY_ISSUER, WORKSPACE_HOST, ...(admin ? [admin] : [])]
34
+ .some((site) => url.origin === resolveSiteOrigin(site, env));
35
+ }
package/src/url.ts ADDED
@@ -0,0 +1,16 @@
1
+ export type SiteEnv = "development" | "production";
2
+
3
+ export function resolveSiteEnv(
4
+ hint?: boolean | "development" | "production",
5
+ ): SiteEnv {
6
+ if (hint === "development" || hint === "production") return hint;
7
+ if (typeof hint === "boolean") return hint ? "development" : "production";
8
+ try {
9
+ const nodeEnv = (globalThis as { process?: { env?: { NODE_ENV?: string } } })
10
+ .process?.env?.NODE_ENV;
11
+ if (nodeEnv === "production") return "production";
12
+ } catch {
13
+ // ignore
14
+ }
15
+ return "development";
16
+ }
@@ -0,0 +1,46 @@
1
+ import { resolveSiteOrigin, type SiteOriginRef } from "./platform";
2
+ import { resolveSiteEnv, type SiteEnv } from "./url";
3
+
4
+ /** First-party workspace host (`apps/app`). */
5
+ export const WORKSPACE_HOST: SiteOriginRef = {
6
+ localPort: 3008,
7
+ productionHost: "app.moloo.dev",
8
+ };
9
+
10
+ /** Gateway product surface for the workspace host. */
11
+ export const WORKSPACE_HOST_API = {
12
+ id: "app",
13
+ apiVersion: "v1",
14
+ name: "App",
15
+ } as const;
16
+
17
+ const SLUG_RE = /^[a-z0-9](?:[a-z0-9-]{0,30}[a-z0-9])$/;
18
+ export const WORKSPACE_SLUG_MAX = 32;
19
+ const SLUG_MIN = 2;
20
+
21
+ /** Why a workspace slug was rejected. Hosts map codes to copy. */
22
+ export type WorkspaceSlugInputError = "empty" | "too_short" | "invalid_chars";
23
+
24
+ export function normalizeWorkspaceSlug(
25
+ raw: string,
26
+ ): { ok: true; slug: string } | { ok: false; error: WorkspaceSlugInputError } {
27
+ const slug = raw.trim().toLowerCase();
28
+ if (!slug) return { ok: false, error: "empty" };
29
+ if (slug.length < SLUG_MIN) {
30
+ return { ok: false, error: "too_short" };
31
+ }
32
+ if (!SLUG_RE.test(slug)) {
33
+ return { ok: false, error: "invalid_chars" };
34
+ }
35
+ return { ok: true, slug };
36
+ }
37
+
38
+ export function getWorkspaceHostUrl(
39
+ env: SiteEnv = resolveSiteEnv(),
40
+ ): string {
41
+ return resolveSiteOrigin(WORKSPACE_HOST, env);
42
+ }
43
+
44
+ export function resolveWorkspaceHostDevPort(): number {
45
+ return WORKSPACE_HOST.localPort;
46
+ }
package/README.md DELETED
@@ -1,3 +0,0 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.