-
Notifications
You must be signed in to change notification settings - Fork 0
feat(tokmetric): complete governed TikTok video publishing flow #138
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
43 commits
Select commit
Hold shift + click to select a range
7acda08
feat(tokmetric): add TikTok publishing contracts and chunk planning
support371 d662c19
feat(tokmetric): add official TikTok Content Posting adapter
support371 8206c1f
feat(tokmetric): expose scoped TikTok connector credentials safely
support371 567117b
feat(tokmetric): add explicit production and sandbox publishing gates
support371 0a76b5a
feat(tokmetric): add governed TikTok video publishing service
support371 ffd8452
feat(tokmetric): expose authenticated video publishing context
support371 8dea500
feat(tokmetric): add creator-info publishing endpoint
support371 e4f0853
feat(tokmetric): add governed TikTok video initialization endpoint
support371 f57ddc9
feat(tokmetric): add video upload completion endpoint
support371 ab9d1bc
feat(tokmetric): add TikTok publish status endpoint
support371 e6e8a39
feat(tokmetric): add TikTok publish cancellation endpoint
support371 dd8caf9
feat(tokmetric): add functional TikTok video publishing interface
support371 897b2c2
feat(tokmetric): mount live video publisher in publishing workspace
support371 7d43087
fix(tokmetric): align validated video publish payload types
support371 45d927c
docs(tokmetric): add video publishing activation variables
support371 9a35ca7
test(tokmetric): cover video chunking and verified URL controls
support371 de60cee
docs(tokmetric): document complete video publishing flow
support371 91d2c8e
refactor(tokmetric): add strictly typed TikTok video publisher
support371 4c87aab
refactor(tokmetric): use strictly typed video publisher implementation
support371 9e0d546
fix(tokmetric): remove unsupported publish cancellation route
support371 a3f195d
docs(tokmetric): align publishing lifecycle with supported TikTok APIs
support371 13188ea
refactor(tokmetric): replace TikTok adapter without unsupported cance…
support371 b06dba5
refactor(tokmetric): restore supported TikTok adapter operations only
support371 b53ad3d
fix(tokmetric): preserve fail-closed cancellation type compatibility
support371 afdac25
fix(tokmetric): type TikTok API envelopes explicitly
support371 a3b0f63
fix(tokmetric): request only demonstrated TikTok posting scopes
support371 6472a13
docs(tokmetric): add final TikTok app review submission copy
support371 38c2bc9
fix(tokmetric): type creator-info payload explicitly
support371 b9372ca
fix(tokmetric): type publish-status payload explicitly
support371 b43b68f
fix(tokmetric): type upload-complete payload explicitly
support371 31cdfac
fix(tokmetric): type video-init payload explicitly
support371 365c3fd
feat(tokmetric): add restricted PULL_FROM_URL cancellation
support371 c940a0c
feat(tokmetric): expose restricted URL-download cancellation endpoint
support371 49bb378
docs(tokmetric): document restricted URL-download cancellation
support371 823a295
feat(tokmetric): make real video publisher the primary review page
support371 2f04f05
fix(tokmetric): enforce multi-chunk uploads above 64 MiB
support371 3512550
test(tokmetric): cover 64 MiB chunk boundary
support371 38fb5b1
fix(tokmetric): bind publishing gates to active TikTok environment
support371 6e45967
test(tokmetric): cover environment-bound publishing gates
support371 4c24680
feat(tokmetric): implement TikTok audit-compliant publishing UX
support371 5a2e615
feat(tokmetric): activate TikTok audit-compliant publisher UX
support371 9eec5b6
fix(tokmetric): enforce branded-content visibility server-side
support371 498764f
refactor(tokmetric): remove obsolete pre-audit publisher UI
support371 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,3 +1,98 @@ | ||
| # TokMetric media publishing | ||
|
|
||
| This document is maintained by the TokMetric eight-phase production program. Phase-specific implementation details will be expanded as each phase lands. Live TikTok publishing remains disabled by default across all phases until the production activation gate is satisfied. | ||
| ## Implemented flow | ||
|
|
||
| TokMetric supports a governed end-to-end TikTok video publishing workflow: | ||
|
|
||
| 1. An authenticated operator selects a TokMetric workspace. | ||
| 2. The operator selects a connected TikTok Content Posting API account. | ||
| 3. The operator selects an approved content version. | ||
| 4. TokMetric queries TikTok's latest creator information before rendering publishing controls. | ||
| 5. The operator chooses one of the privacy levels returned for that creator. | ||
| 6. The operator configures comments, Duet, Stitch, commercial-content disclosure, and AI-generated-content disclosure. | ||
| 7. The operator explicitly confirms upload consent, video rights, music rights, and the TikTok processing notice. | ||
| 8. TokMetric initializes the Direct Post request through TikTok's official Content Posting API. | ||
| 9. A local MP4, MOV, or WebM file is uploaded directly from the browser to TikTok's temporary upload URL in sequential chunks. | ||
| 10. A server-hosted video can use `PULL_FROM_URL` only when its hostname is included in `TOKMETRIC_VERIFIED_MEDIA_HOSTS` and has been verified in the TikTok Developer Portal. | ||
| 11. TokMetric polls the official post-status endpoint and records the internal and external state of the publishing job. | ||
| 12. Operators can refresh the status until TikTok reports a final success or failure state. | ||
|
|
||
| TikTok cancellation is restricted to an ongoing `PULL_FROM_URL` download and is best-effort. Local file uploads and posts that have already entered processing are not presented as cancellable. | ||
|
|
||
| Access tokens, refresh tokens, client secrets, and TikTok upload URLs are never persisted in browser storage or returned through logs and audit metadata. | ||
|
|
||
| ## Activation gates | ||
|
|
||
| Publishing remains fail-closed. | ||
|
|
||
| ### Sandbox review | ||
|
|
||
| Set all of the following: | ||
|
|
||
| ```text | ||
| TIKTOK_ENVIRONMENT=sandbox | ||
| TOKMETRIC_TIKTOK_OAUTH_ENABLED=true | ||
| TOKMETRIC_SANDBOX_PUBLISHING_ENABLED=true | ||
| TOKMETRIC_LIVE_PUBLISHING_ENABLED=false | ||
| ``` | ||
|
|
||
| Sandbox publishing is restricted by the application to `SELF_ONLY` privacy. The workspace must also have `publishingDisabled=false`, no global emergency lock, an approved content version, and a valid connector carrying `video.publish`. | ||
|
|
||
| ### Production | ||
|
|
||
| Set: | ||
|
|
||
| ```text | ||
| TIKTOK_ENVIRONMENT=production | ||
| TOKMETRIC_TIKTOK_OAUTH_ENABLED=true | ||
| TOKMETRIC_LIVE_PUBLISHING_ENABLED=true | ||
| TOKMETRIC_SANDBOX_PUBLISHING_ENABLED=false | ||
| ``` | ||
|
|
||
| Production activation should occur only after TikTok approves the required product and scopes and the app review is complete. | ||
|
|
||
| ## Transfer modes | ||
|
|
||
| ### FILE_UPLOAD | ||
|
|
||
| Use this mode when the video is on the user's device. The browser uploads the video directly to TikTok using the temporary upload URL returned by TikTok. TokMetric plans sequential chunks that follow TikTok's 5 MB minimum, 64 MB normal maximum, 128 MB final-chunk allowance, 1,000-chunk maximum, and 4 GB video maximum. | ||
|
|
||
| ### PULL_FROM_URL | ||
|
|
||
| Use this mode only when the video already exists on server-side storage. Configure a comma-separated allowlist: | ||
|
|
||
| ```text | ||
| TOKMETRIC_VERIFIED_MEDIA_HOSTS=gemcybersecurityassist.com,www.gemcybersecurityassist.com | ||
| ``` | ||
|
|
||
| Every configured domain or URL prefix must also be verified in the TikTok Developer Portal. URLs must be HTTPS, must not contain credentials or fragments, and must not redirect. An ongoing URL download may be cancelled through the restricted cancellation route until TikTok reports that it is no longer cancellable. | ||
|
|
||
| ## Operational routes | ||
|
|
||
| - `GET /api/tokmetric/publishing/context` | ||
| - `POST /api/tokmetric/publishing/creator-info` | ||
| - `POST /api/tokmetric/publishing/init` | ||
| - `POST /api/tokmetric/publishing/upload-complete` | ||
| - `POST /api/tokmetric/publishing/status` | ||
| - `POST /api/tokmetric/publishing/cancel` — `PULL_FROM_URL` only | ||
|
|
||
| All routes require an authenticated TokMetric session, workspace access, and the `publish:content` permission where a workspace role is present. | ||
|
|
||
| ## App review recording | ||
|
|
||
| Record the working flow on `/tokmetric/publishing` while the TikTok app is in sandbox mode: | ||
|
|
||
| 1. Sign in to the GEM Enterprise demo account. | ||
| 2. Open TokMetric Publishing. | ||
| 3. Select the review workspace and connected sandbox TikTok account. | ||
| 4. Select an approved content item. | ||
| 5. Click **Query creator settings** and show the returned TikTok nickname and privacy options. | ||
| 6. Select a local video file. | ||
| 7. Edit the caption and choose `SELF_ONLY`. | ||
| 8. Show comment, Duet, Stitch, paid-partnership, own-business, and AI-generated-content controls. | ||
| 9. Check each consent and rights confirmation manually. | ||
| 10. Click **Send video to TikTok**. | ||
| 11. Show chunk-upload progress and the TikTok processing status. | ||
| 12. Open the TikTok sandbox account and show the resulting private post when processing completes. | ||
|
|
||
| Do not submit a mockup-only recording. The review video should show real user interactions and a real sandbox publishing request. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,61 @@ | ||
| # TikTok App Review Submission — Alliance-Trust Hub / TokMetric | ||
|
|
||
| ## Copy for “Explain how each product and scope works” | ||
|
|
||
| Use this text in the TikTok review form. It is under the 1,000-character limit: | ||
|
|
||
| > Alliance-Trust Hub uses TikTok Login Kit so a user can connect their own TikTok account through TikTok OAuth. The `user.info.basic` scope is used to show the connected creator username and nickname. TokMetric uses the Content Posting API with `video.publish` only after the user selects an approved video, refreshes the creator settings, edits the caption, chooses one of TikTok’s returned privacy options, configures comments/Duet/Stitch and commercial or AI-content disclosures, confirms video and music rights, and expressly clicks “Send video to TikTok.” Local files upload directly from the user’s browser to TikTok’s temporary upload URL. The app then polls TikTok’s status endpoint and shows processing, success, or failure. TikTok passwords and access tokens are never displayed or stored in the browser. Sandbox demonstrations use `SELF_ONLY` privacy. | ||
|
|
||
| ## Products to select | ||
|
|
||
| Select only the products demonstrated in the recording: | ||
|
|
||
| - Login Kit | ||
| - Content Posting API — Direct Post | ||
|
|
||
| Do not select Display API, Share Kit, TikTok Shop, Business API, or advertising products unless the submitted recording demonstrates those products end to end. | ||
|
|
||
| ## Scopes to request | ||
|
|
||
| - `user.info.basic` | ||
| - `video.publish` | ||
|
|
||
| Do not request `video.upload` for this Direct Post review flow. `video.upload` is used for the separate upload-to-inbox/draft experience and is not required by the implemented Direct Post workflow. | ||
|
|
||
| ## Demo video recording sequence | ||
|
|
||
| Record one continuous MP4 or MOV video showing: | ||
|
|
||
| 1. The browser address bar displaying the verified GEM domain. | ||
| 2. Sign-in to the GEM Enterprise review account. | ||
| 3. Open `/tokmetric/publishing`. | ||
| 4. Select the review workspace. | ||
| 5. Select the connected TikTok sandbox account. | ||
| 6. Select an approved content record. | ||
| 7. Click **Query creator settings**. | ||
| 8. Show the returned TikTok nickname, username, maximum duration, and privacy options. | ||
| 9. Select a local MP4/MOV/WebM video. | ||
| 10. Edit the caption. | ||
| 11. Choose `SELF_ONLY` for sandbox review. | ||
| 12. Show the comments, Duet, Stitch, paid-partnership, own-business, and AI-generated-content controls. | ||
| 13. Manually check all four consent and rights confirmations. | ||
| 14. Click **Send video to TikTok**. | ||
| 15. Show upload progress. | ||
| 16. Show TikTok processing status in TokMetric. | ||
| 17. Open the authorized TikTok sandbox/test account and show the resulting private post after processing completes. | ||
| 18. Briefly show the public Privacy Policy and Terms of Service pages. | ||
|
|
||
| The recording must show real clicks and a real sandbox API request. Do not submit only static mockups or narrated slides. | ||
|
|
||
| ## Before clicking “Submit for review” | ||
|
|
||
| - The review account can sign in successfully. | ||
| - The review workspace has `publishingDisabled=false`. | ||
| - The content item has an approved current version and matching approval hash. | ||
| - The Content Posting connector is `CONNECTED` and carries `user.info.basic` and `video.publish`. | ||
| - `TIKTOK_ENVIRONMENT=sandbox`. | ||
| - `TOKMETRIC_TIKTOK_OAUTH_ENABLED=true`. | ||
| - `TOKMETRIC_SANDBOX_PUBLISHING_ENABLED=true`. | ||
| - `TOKMETRIC_LIVE_PUBLISHING_ENABLED=false`. | ||
| - The redirect URI exactly matches the TikTok Developer Portal value. | ||
| - The uploaded demo file is MP4 or MOV and is no larger than 50 MB, matching the review form’s upload limit. | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,110 @@ | ||
| import { afterEach, describe, expect, it } from "vitest"; | ||
| import { | ||
| calculateTikTokChunkPlan, | ||
| chunkByteRange, | ||
| } from "@/lib/tokmetric/publishing/types"; | ||
| import { getTokMetricPublishingGate } from "@/lib/tokmetric/publishing/gates"; | ||
| import { validateVerifiedMediaUrl } from "@/lib/tokmetric/publishing/service"; | ||
|
|
||
| const originalEnv = { ...process.env }; | ||
|
|
||
| afterEach(() => { | ||
| process.env = { ...originalEnv }; | ||
| }); | ||
|
|
||
| describe("TikTok video upload planning", () => { | ||
| it("uploads files smaller than 5 MiB as one complete chunk", () => { | ||
| const plan = calculateTikTokChunkPlan(2 * 1024 * 1024); | ||
| expect(plan).toEqual({ | ||
| videoSize: 2 * 1024 * 1024, | ||
| chunkSize: 2 * 1024 * 1024, | ||
| totalChunkCount: 1, | ||
| }); | ||
| expect(chunkByteRange(plan, 0)).toEqual({ | ||
| start: 0, | ||
| end: 2 * 1024 * 1024 - 1, | ||
| length: 2 * 1024 * 1024, | ||
| }); | ||
| }); | ||
|
|
||
| it("keeps a 64 MiB video as one valid chunk", () => { | ||
| const size = 64 * 1024 * 1024; | ||
| expect(calculateTikTokChunkPlan(size)).toEqual({ | ||
| videoSize: size, | ||
| chunkSize: size, | ||
| totalChunkCount: 1, | ||
| }); | ||
| }); | ||
|
|
||
| it("uses at least two chunks immediately above 64 MiB", () => { | ||
| const size = 65 * 1024 * 1024; | ||
| const plan = calculateTikTokChunkPlan(size); | ||
| expect(plan.totalChunkCount).toBe(2); | ||
| expect(chunkByteRange(plan, 0).length).toBeLessThanOrEqual(64 * 1024 * 1024); | ||
| expect(chunkByteRange(plan, 1).end).toBe(size - 1); | ||
| }); | ||
|
|
||
| it("uses sequential chunks and merges the trailing bytes into the final request", () => { | ||
| const videoSize = 150 * 1024 * 1024; | ||
| const plan = calculateTikTokChunkPlan(videoSize); | ||
| expect(plan.totalChunkCount).toBe(2); | ||
| expect(chunkByteRange(plan, 0).start).toBe(0); | ||
| const final = chunkByteRange(plan, 1); | ||
| expect(final.end).toBe(videoSize - 1); | ||
| expect(final.length).toBe(videoSize - plan.chunkSize); | ||
| }); | ||
|
|
||
| it("rejects files larger than TikTok's 4 GB maximum", () => { | ||
| expect(() => calculateTikTokChunkPlan(4 * 1024 * 1024 * 1024 + 1)).toThrow(/4 GB/); | ||
| }); | ||
| }); | ||
|
|
||
| describe("TikTok publishing activation gates", () => { | ||
| it("does not let the production flag activate a sandbox environment", () => { | ||
| process.env.TIKTOK_ENVIRONMENT = "sandbox"; | ||
| process.env.TOKMETRIC_LIVE_PUBLISHING_ENABLED = "true"; | ||
| process.env.TOKMETRIC_SANDBOX_PUBLISHING_ENABLED = "false"; | ||
| const gate = getTokMetricPublishingGate(); | ||
| expect(gate.enabled).toBe(false); | ||
| expect(gate.configurationMismatch).toBe(true); | ||
| }); | ||
|
|
||
| it("does not let the sandbox flag activate a production environment", () => { | ||
| process.env.TIKTOK_ENVIRONMENT = "production"; | ||
| process.env.TOKMETRIC_LIVE_PUBLISHING_ENABLED = "false"; | ||
| process.env.TOKMETRIC_SANDBOX_PUBLISHING_ENABLED = "true"; | ||
| const gate = getTokMetricPublishingGate(); | ||
| expect(gate.enabled).toBe(false); | ||
| expect(gate.configurationMismatch).toBe(true); | ||
| }); | ||
|
|
||
| it("activates only the matching sandbox gate", () => { | ||
| process.env.TIKTOK_ENVIRONMENT = "sandbox"; | ||
| process.env.TOKMETRIC_LIVE_PUBLISHING_ENABLED = "false"; | ||
| process.env.TOKMETRIC_SANDBOX_PUBLISHING_ENABLED = "true"; | ||
| expect(getTokMetricPublishingGate()).toMatchObject({ | ||
| environment: "sandbox", | ||
| enabled: true, | ||
| mode: "sandbox", | ||
| }); | ||
| }); | ||
| }); | ||
|
|
||
| describe("TikTok verified media URL controls", () => { | ||
| it("accepts HTTPS URLs on configured domains and subdomains", () => { | ||
| process.env.TOKMETRIC_VERIFIED_MEDIA_HOSTS = "gemcybersecurityassist.com"; | ||
| expect(validateVerifiedMediaUrl("https://media.gemcybersecurityassist.com/videos/demo.mp4").hostname) | ||
| .toBe("media.gemcybersecurityassist.com"); | ||
| }); | ||
|
|
||
| it("rejects unverified domains and URL credentials", () => { | ||
| process.env.TOKMETRIC_VERIFIED_MEDIA_HOSTS = "gemcybersecurityassist.com"; | ||
| expect(() => validateVerifiedMediaUrl("https://example.com/demo.mp4")).toThrow(/approved TikTok URL property/); | ||
| expect(() => validateVerifiedMediaUrl("https://user:pass@gemcybersecurityassist.com/demo.mp4")).toThrow(/without credentials/); | ||
| }); | ||
|
|
||
| it("fails closed when no verified media hosts are configured", () => { | ||
| delete process.env.TOKMETRIC_VERIFIED_MEDIA_HOSTS; | ||
| expect(() => validateVerifiedMediaUrl("https://gemcybersecurityassist.com/demo.mp4")).toThrow(/disabled/); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,43 @@ | ||
| import { NextRequest, NextResponse } from "next/server"; | ||
| import { z } from "zod"; | ||
| import { | ||
| correlationId, | ||
| parseJson, | ||
| requirePermission, | ||
| requireTokMetricSession, | ||
| requireWorkspaceAccess, | ||
| tokMetricErrorResponse, | ||
| } from "@/lib/tokmetric/security"; | ||
| import { cancelPullFromUrlPublish } from "@/lib/tokmetric/publishing/pullCancel"; | ||
|
|
||
| const schema = z.object({ | ||
| workspaceId: z.string().min(1), | ||
| jobId: z.string().min(1), | ||
| }); | ||
|
|
||
| type CancelPayload = { | ||
| workspaceId: string; | ||
| jobId: string; | ||
| }; | ||
|
|
||
| export async function POST(request: NextRequest) { | ||
| const cid = correlationId(request); | ||
| try { | ||
| const session = await requireTokMetricSession(request); | ||
| const input = await parseJson(request, schema) as CancelPayload; | ||
| const membership = await requireWorkspaceAccess(input.workspaceId, session); | ||
| requirePermission(membership, "publish", "content"); | ||
| const job = await cancelPullFromUrlPublish({ | ||
| workspaceId: input.workspaceId, | ||
| jobId: input.jobId, | ||
| actorId: session.userId, | ||
| correlationId: cid, | ||
| }); | ||
| return NextResponse.json( | ||
| { ok: true, correlationId: cid, data: job }, | ||
| { headers: { "Cache-Control": "no-store" } }, | ||
| ); | ||
| } catch (error) { | ||
| return tokMetricErrorResponse(error, cid); | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,19 @@ | ||
| import { NextRequest, NextResponse } from "next/server"; | ||
| import { correlationId, requireTokMetricSession, tokMetricErrorResponse } from "@/lib/tokmetric/security"; | ||
| import { getVideoPublishingContext } from "@/lib/tokmetric/publishing/service"; | ||
|
|
||
| export const dynamic = "force-dynamic"; | ||
|
|
||
| export async function GET(request: NextRequest) { | ||
| const cid = correlationId(request); | ||
| try { | ||
| const session = await requireTokMetricSession(request); | ||
| const context = await getVideoPublishingContext(session); | ||
| return NextResponse.json( | ||
| { ok: true, correlationId: cid, data: context }, | ||
| { headers: { "Cache-Control": "no-store, max-age=0" } }, | ||
| ); | ||
| } catch (error) { | ||
| return tokMetricErrorResponse(error, cid); | ||
| } | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
issue (typo): Use the verb phrase "Sign in" instead of the noun form "Sign-in" here for correct grammar and consistency.
Other references in the docs use "Sign in" as a verb (e.g., "Sign in to the GEM Enterprise demo account" and "The review account can sign in successfully"). Update this step to match: "Sign in to the GEM Enterprise review account."